Compare commits
4 Commits
main
...
9604c3e9ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 9604c3e9ae | |||
| 3a474f750c | |||
| 4e6fba7cb9 | |||
| fb4d55cbcd |
@@ -6,26 +6,26 @@
|
||||
|
||||
## Orientation
|
||||
|
||||
- **live_sha** (Dalidou `/health` build_sha): `23cdb31` (verified 2026-04-29T17:04:05Z post V1-A deploy; status=ok)
|
||||
- **last_updated**: 2026-04-29 by Claude (Wave 1 + Wave 1.5 + V1-A all squash-merged and deployed today)
|
||||
- **main_tip**: `23cdb31`
|
||||
- **test_count**: 605 on `main` (572 + 14 Wave 1 + 10 Wave 1.5 + 9 V1-A)
|
||||
- **harness**: `20/20 PASS` on live Dalidou against `23cdb31`, 0 blocking failures, 0 known issues
|
||||
- **live_sha** (Dalidou `/health` build_sha): `7042eae` (verified 2026-04-29T01:19Z; status=ok; deployed 2026-04-25T01:04Z, docs-only on top of `d3de9f6`)
|
||||
- **last_updated**: 2026-04-29 by Claude (Wave 1 debt-pay branch open; Codex review of plan applied)
|
||||
- **main_tip**: `7042eae`
|
||||
- **test_count**: 586 on `claude/wave1-dashboard-counts-and-memory-fixes` (572 on main + 14 Wave 1 regressions; +5 from Codex review amends)
|
||||
- **harness**: `20/20 PASS` on live Dalidou, 0 blocking failures, 0 known issues (last harness run nightly 2026-04-28T03:00:30Z)
|
||||
- **vectors**: 33,253
|
||||
- **active_memories**: 1170 dashboard (live SQL post-fix) vs 1091 integrity-panel snapshot (last nightly 2026-04-28T03:00:30Z; difference is ~80 captures since the snapshot — no longer a sampling artifact). Total memories: 2436. Pre-deploy the dashboard reported 315 due to a confidence-sorted limit=500 sampling bug, now closed.
|
||||
- **candidate_memories**: 44 (live as of 2026-04-29T02:00Z; queue accumulated since the 2026-04-28 nightly autotriage)
|
||||
- **active_memories**: 315 dashboard / 1091 integrity (verified live 2026-04-29). Discrepancy was a dashboard sampling bug — Wave 1 commit `fb4d55c` replaces it with SQL aggregates; post-deploy the two will agree.
|
||||
- **candidate_memories**: 0 (queue drained, +103 captures since 2026-04-25)
|
||||
- **interactions**: 1054 (claude-code 474, openclaw 576; verified `/admin/dashboard` 2026-04-29)
|
||||
- **registered_projects** (8): atocore, p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, abb-space (aliased p08), **apm** (registered 2026-04-29 — Atomaste Parts Manager I03), **openclaw** (registered 2026-04-29 — external LLM agent harness, read-only pull integration; alias `clawd`). `/admin/projects/proposals?min_active=10` is now empty.
|
||||
- **registered_projects**: atocore, p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, abb-space (aliased p08). **Auto-detected, unregistered**: apm (63 active memories), openclaw (9), lead-space (2), drill (1), optiques-fullum (1) — see Wave 1 follow-up below.
|
||||
- **project_state_entries**: 128 across registered projects (verified 2026-04-29)
|
||||
- **entities**: 66 (V1-0 backfill complete; 0 open conflicts)
|
||||
- **off_host_backup**: `papa@192.168.86.39:/home/papa/atocore-backups/` via cron, verified
|
||||
- **nightly_pipeline**: backup → cleanup → rsync → OpenClaw import → vault refresh → extract → auto-triage → auto-promote/expire → weekly synth/lint Sundays → retrieval harness → pipeline summary
|
||||
- **capture_clients**: claude-code (Stop hook + cwd project inference), openclaw (before_agent_start + llm_output plugin, verified live)
|
||||
- **wiki**: http://dalidou:8100/wiki (browse), /wiki/projects/{id}, /wiki/entities/{id}, /wiki/search
|
||||
- **dashboard**: http://dalidou:8100/admin/dashboard (now reports SQL-aggregate counts; new fields: `memories.total`, `memories.by_status`)
|
||||
- **active_track**: Wave 1 + Wave 1.5 + V1-A closed → V1-B is the next phase (KB-CAD/KB-FEM ingest + D-2 schema docs per the V1 completion plan). Plan: `docs/plans/engineering-v1-completion-plan.md`. Resume map: `docs/plans/v1-resume-state.md`.
|
||||
- **dashboard**: http://dalidou:8100/admin/dashboard
|
||||
- **active_track**: Wave 1 debt-pay (this session) → V1-A start. V1-A gates have cleared (soak ended 2026-04-26; density 315 ≫ 100). Plan: `docs/plans/engineering-v1-completion-plan.md`. Resume map: `docs/plans/v1-resume-state.md`.
|
||||
- **last_nightly_pipeline**: `2026-04-28T03:00:30Z` — harness 20/20, triage promoted=1 rejected=1 human=0
|
||||
- **open_branches**: none. Wave 1 (`4c70756`), Wave 1.5 (`b69d2c7`), and V1-A (`23cdb31`) all squash-merged and deployed today; branches cleaned up.
|
||||
- **open_branches**: `claude/wave1-dashboard-counts-and-memory-fixes` (tip `3a474f7`) — three memory-write-path bugs + two follow-on P2 fixes from Codex's formal audit (`auto_triage.py` PUT body + `/memory/{id}/supersede` status guard). Awaiting Codex re-review of the amended branch before squash-merge/deploy.
|
||||
|
||||
## Active Plan
|
||||
|
||||
@@ -170,16 +170,6 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
|
||||
|
||||
## Session Log
|
||||
|
||||
- **2026-04-29 Codex + Claude (V1-A landed and deployed)** Engineering V1 Completion Plan Phase V1-A done. Branch `claude/v1-a-pillar-query-slice` shipped the Q-001 subsystem-scoped shape fix exactly per `engineering-query-catalog.md:71`: new `subsystem_contents()` helper in `queries.py` walking inbound `part_of` edges, new `GET /entities/Subsystem/{id}?expand=contains` route returning `{subsystem, contains: [{id, type, entity_type, name, status}]}`, dual-emit of `type` (spec) and `entity_type` (rest-of-module convention) for compat. The existing project-wide `/engineering/projects/{name}/systems` route stays for Q-004. Aliased under `/v1`. V1-A acceptance integration test runs all four pillar queries (Q-001 subsystem, Q-005 requirements_for, Q-006 orphan_requirements, Q-017 evidence_chain) plus Q-009/Q-011 against a single p05-interferometer seed graph. Codex audited at `b575773` (GO WITH CONDITIONS, two P2s + two P3s) → all four amends folded in at `0e83525` → re-review GO. Squash-merged to main as `23cdb31`. Deployed via canonical script; live `/health` build_sha `23cdb3149fde2185f0eaf1350c5b065b08f80319`, build_time `2026-04-29T17:04:05Z`, status=ok. **Live post-deploy probes** all green: `/entities/Subsystem/{p05-id}?expand=contains` → 200, spec-shaped response; `/v1/entities/Subsystem/{p05-id}?expand=contains` → 200; `?expand=parents` → 400 with informative detail; component-id against the Subsystem route → 404 with informative detail. Live retrieval harness 20/20 against `23cdb31`. Tests on main: 596 → 605 (+9). master-plan-status.md V1-A status flipped to ✅; V1-B is now "next".
|
||||
|
||||
- **2026-04-29 Antoine + Claude (openclaw registered, proposals queue drained)** Talked through the framing tension — openclaw is "a tool" but external (we don't own its product), so the 17 memories are really integration notes (plugin architecture, `cleanedBody` hook field, read-only pull contract, hermes-agent comparison, atocore-capture v0.2.0 plugin). Picked Option A from the four options laid out: register `openclaw` straight (zero migration) with description that names the integration framing explicitly. Aliases: `clawd` (matches local repo path on Dalidou + T420). POSTed to `/admin/projects/register-emerging`. Live `/admin/projects/proposals?min_active=10` now returns count=0 — registered project set is 8, no unregistered labels above threshold. Wave 1.5 closed end-to-end: code + audit + deploy + both registrations.
|
||||
|
||||
- **2026-04-29 Antoine + Claude (apm registered)** Antoine confirmed apm is a product alongside atomizer-v2 (I03 in the Atomaste tools I-series). Registered via `POST /admin/projects/register-emerging` mirroring atomizer-v2's pattern: aliases `i03`, `atomaste-parts-manager`, `parts-manager`; description "Atomaste Parts Manager — parts/COTS lifecycle tool (I03 in Atomaste tool series alongside I01 Atomizer-v2 and I02 AtoCore)". Default ingest_root `vault:incoming/projects/apm/` (does not yet exist on disk; may want a `/repo` mirror via PUT /projects/apm if a code repo gets staged, like atomizer-v2's `incoming/projects/atomizer-v2/repo`). Live `/admin/projects/proposals?min_active=10` post-registration: count drops 2→1 (apm gone, openclaw remains). 165 active + 22 candidate apm-tagged memories now resolve to a registered canonical id.
|
||||
|
||||
- **2026-04-29 Codex + Claude (Wave 1.5 audited, merged, deployed)** Branch `claude/wave1.5-emerging-project-proposals` shipped `GET /admin/projects/proposals?min_active=N` — live, on-demand companion to the nightly `scripts/detect_emerging.py` cache. Each proposal includes project_id, active+candidate counts, sample_memories (3 most recent), suggested_aliases (sibling labels sharing a >=4-char token, case-insensitive), guessed_ingest_root. New `propose_emerging_projects()` helper in `service.py`. Codex audited tip `e8ac8bb`: verdict GO with one P2 (test was trivially true) and one P3 (case-sensitive tokens). Both folded into `f70fa6b` plus a registered-token-leak guard test. Tip `f70fa6b` squash-merged to main as `b69d2c7`. Deployed via canonical script; live `/health` reports build_sha `b69d2c70881b661e6fd8f25194cbc5c20a3947be`, build_time `2026-04-29T02:16:25Z`, status=ok. Live `/admin/projects/proposals?min_active=10` returns the expected 2 proposals: **apm (165 active / 22 candidate, no aliases)** and **openclaw (17 active / 0 candidate, no aliases)**. Hydrotech and lead-space variants below threshold or fewer captures than the dashboard `by_project` showed pre-deploy (live SQL is current). apm sample memories are clearly real APM project content (parts manager, McMaster cleanup workflows). Tests: 586 → 596 (+10). Branches deleted local + remote. **Open Wave 1.5 follow-on (P2)**: Codex recommended a one-step shortcut so the operator doesn't have to copy `suggested_aliases` from the proposal JSON into the register-emerging POST — e.g., `POST /admin/projects/register-emerging` with `from_proposal: {project_id}` that reads server-side. Deferred — the JSON copy is acceptable for the small number of projects in flight. Next: register apm (operator decision pending).
|
||||
|
||||
- **2026-04-29 Claude (Wave 1 deployed, post-deploy probes green)** Squash-merged `claude/wave1-dashboard-counts-and-memory-fixes` (tip `9604c3e`) to main as `4c70756` after Codex's GO verdict on the amended branch. Pushed main, deployed via `ssh papa@dalidou "bash /srv/storage/atocore/app/deploy/dalidou/deploy.sh"`. Live `/health` reports build_sha `4c7075650c8187473bc5992b306fb8fa67542074`, build_time `2026-04-29T01:58:59Z`, status=ok. **Post-deploy acceptance** per Codex's checklist: (1) `/admin/dashboard memories.active = 1170` — was 315 pre-deploy, sampling bug closed; new fields `memories.total = 2436`, `memories.by_status` and full `memories.by_project` breakdown live; integrity panel still reports the 22h-old 1091 snapshot from last nightly which is expected behavior. (2) Live retrieval harness 20/20 PASS against `4c70756`, no regression. (3) Supersede/invalidate route branches probed live: unknown id → 404; candidate target → 409 with status-specific detail (`...is candidate; only active memories can be superseded`, `...is candidate; use /reject for candidates`). (4) Auto-triage suggested-project correction path covered by the script-source invariant test plus end-to-end TestClient coverage of the new `MemoryUpdateRequest.project` plumbing — not exercised against live prod state to avoid casual mutation. **Live by-project dashboard breakdown** revealed 14 unregistered projects accumulating memories: apm (165!), openclaw (17), hydrotech-mining variants (13), lead-space variants (5), and ten others. apm is severely overdue. Wave 1.5 starts next: one-click registration proposal endpoint with sample-memory + alias suggestions. **Test count**: 586 on main. **Open branches**: none — Wave 1 closed.
|
||||
|
||||
- **2026-04-29 Codex + Claude (Wave 1 formal audit closed + amends)** Codex's formal audit of `fb4d55c` (Wave 1 first commit on `claude/wave1-dashboard-counts-and-memory-fixes`): verdict GO WITH CONDITIONS. Two P1-prior closures confirmed (dashboard count bug + invalidate top-1 lookup). Project-update P2 was only "partially closed" — API/service plumbing fine, but `scripts/auto_triage.py:417` still PUT `{"content": cand["content"]}` so the operational suggested-project correction was unreachable even with `MemoryUpdateRequest.project` in place. Codex also flagged the symmetric supersede-route gap as same-class adjacent surface and recommended pulling it in here, not in Wave 1.5. Plus one P3: cover retarget-to-empty-project against a global active duplicate. Amended on `3a474f7`: (1) auto_triage PUT body now `{"project": suggested}` with a guard test that lints the script source for the new shape; (2) `/memory/{id}/supersede` mirrors the invalidate guard via `get_memory(id)` — 404 unknown / 200 already_superseded / 409 wrong-status / 200 superseded; (3) regression test for project-empty duplicate detection. Test count 581 → 586. Codex's recommended deployment checklist (post-deploy verifications, including the run-or-simulate auto-triage retarget probe) carried into the merge plan. Awaiting Codex re-review of the amended tip before squash-merge.
|
||||
|
||||
- **2026-04-29 Claude (Wave 1 debt-pay started; Codex review of state-of-service plan)** Audited live state on Dalidou: `/health` build_sha `7042eae` (4d old), harness 20/20, 33,253 vectors, 1,748 docs, 1054 interactions (+103/4d), dashboard memories.active=315 vs integrity.active_memory_count=1091. Drafted a state-of-service assessment + Wave 1/2/3/4 plan, then asked Codex (gpt-5.5) for an adversarial review via `codex exec`. Codex verdict: assessment MIXED, plan ENDORSE WITH CHANGES. Codex caught two factual corrections (the 315-vs-1091 gap is a *sampling bug* not a definitional gap; my "R9 drops unregistered tags" framing is wrong — `extractor_llm.py:213-233` preserves them) and two new memory-write-path bugs I missed. Branched `claude/wave1-dashboard-counts-and-memory-fixes` from `7042eae`, fixed all three: (1) `/admin/dashboard` now uses a new `get_memory_count_summary()` SQL aggregate helper instead of counting inside a confidence-sorted `get_memories(limit=500)` sample; (2) `MemoryUpdateRequest` and `update_memory()` accept `project` with `resolve_project_name` canonicalization + before/after audit, so `auto_triage.py:407` suggested-project corrections will now actually apply; (3) `POST /memory/{id}/invalidate` replaces the `_get_memories(status="active", limit=1)` lookup (which only saw the highest-confidence active row) with a direct id lookup via new `get_memory(id)` helper. 9 regression tests added across `test_memory.py` and `test_invalidate_supersede.py`. Full local suite: 581 passed (572 → 581). Commit `fb4d55c`. Branch not pushed/deployed yet — awaiting Codex audit per working model. Refreshed Orientation block (live_sha, last_updated, test_count, memory counts, capture cadence, registered/unregistered project breakdown, open_branches). Wave 1 follow-up still open: (W1.2) one-click registration proposal for unregistered projects with ≥10 active memories (apm=63 is overdue); (W1.3 done by this entry) sync ledger; (W1.4) committed measurable-win probe fixture+JSON output. After this branch lands, V1-A is unblocked: gates have effectively cleared (soak ended 2026-04-26; density 315 ≫ 100 target).
|
||||
|
||||
@@ -198,8 +198,8 @@ where surfaces are disjoint, pauses when they collide.
|
||||
| Phase | Scope | Status |
|
||||
|---|---|---|
|
||||
| V1-0 | Write-time invariants: F-1 header fields + F-8 provenance enforcement + F-5 hook on every active-entity write + Q-3 flag-never-block | ✅ done 2026-04-22 (`2712c5d`) |
|
||||
| V1-A | Minimum query slice: Q-001 subsystem-scoped variant + Q-6 killer-correctness integration test on p05-interferometer | ✅ done 2026-04-29 (`23cdb31`) |
|
||||
| V1-B | KB-CAD + KB-FEM ingest (`POST /ingest/kb-cad/export`, `POST /ingest/kb-fem/export`) + D-2 schema docs | next |
|
||||
| V1-A | Minimum query slice: Q-001 subsystem-scoped variant + Q-6 killer-correctness integration test on p05-interferometer | 🟡 gated — starts when soak (~2026-04-26) + density (100+ active memories) gates clear |
|
||||
| V1-B | KB-CAD + KB-FEM ingest (`POST /ingest/kb-cad/export`, `POST /ingest/kb-fem/export`) + D-2 schema docs | pending V1-A |
|
||||
| V1-C | Close the remaining 8 queries (Q-002/003/007/010/012/014/018/019; Q-020 to V1-D) | pending V1-B |
|
||||
| V1-D | Full mirror surface (3 spec routes + regenerate + determinism + disputed + curated markers) + Q-5 golden file | pending V1-C |
|
||||
| V1-E | Memory→entity graduation end-to-end + remaining Q-4 trust tests | pending V1-D (note: collides with memory extractor; pauses for multi-model triage work) |
|
||||
|
||||
@@ -474,33 +474,6 @@ def api_register_emerging_project(req: RegisterEmergingRequest) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/admin/projects/proposals")
|
||||
def api_project_proposals(min_active: int = 10) -> dict:
|
||||
"""Live registration proposals for unregistered projects.
|
||||
|
||||
Reads SQL + the registry directly, so the result is current — unlike
|
||||
`/admin/dashboard.proposals.unregistered_projects` which is the
|
||||
nightly cache from `scripts/detect_emerging.py`. Each proposal
|
||||
includes a guessed ingest root, sibling labels suggested as aliases,
|
||||
and a few sample memories so the operator can sanity-check before
|
||||
POSTing to /admin/projects/register-emerging.
|
||||
|
||||
Query params:
|
||||
min_active: minimum active-memory count for a label to surface
|
||||
(default 10).
|
||||
"""
|
||||
from atocore.memory.service import propose_emerging_projects
|
||||
|
||||
if min_active < 1:
|
||||
raise HTTPException(status_code=400, detail="min_active must be >= 1")
|
||||
proposals = propose_emerging_projects(min_active=min_active)
|
||||
return {
|
||||
"proposals": proposals,
|
||||
"count": len(proposals),
|
||||
"min_active": min_active,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/projects/{project_name}")
|
||||
def api_project_update(project_name: str, req: ProjectUpdateRequest) -> dict:
|
||||
"""Update an existing project registration."""
|
||||
@@ -2393,41 +2366,6 @@ def api_entity_audit(entity_id: str, limit: int = 100) -> dict:
|
||||
return {"entity_id": entity_id, "entries": entries, "count": len(entries)}
|
||||
|
||||
|
||||
@router.get("/entities/Subsystem/{subsystem_id}")
|
||||
def api_subsystem_contents(subsystem_id: str, expand: str = "contains") -> dict:
|
||||
"""Q-001 (subsystem-scoped variant) — the spec-shaped invocation.
|
||||
|
||||
Per ``docs/architecture/engineering-query-catalog.md`` Q-001:
|
||||
``GET /entities/Subsystem/<id>?expand=contains``
|
||||
→ ``{ subsystem, contains: [{ id, type, name, status }] }``
|
||||
|
||||
Distinct from the project-wide tree at
|
||||
``GET /engineering/projects/{name}/systems`` (Q-004), which stays
|
||||
as-is. V1-A only adds this single shape fix; broader query catalog
|
||||
closure is V1-C.
|
||||
|
||||
``expand`` is currently restricted to ``"contains"``; other expand
|
||||
facets (Q-007 ``constraints``, Q-008 ``decisions``) land in V1-C.
|
||||
"""
|
||||
if expand != "contains":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"unsupported expand={expand!r} for /entities/Subsystem; "
|
||||
"V1-A supports only 'contains'."
|
||||
),
|
||||
)
|
||||
from atocore.engineering.queries import subsystem_contents
|
||||
|
||||
result = subsystem_contents(subsystem_id)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Subsystem not found or not a subsystem: {subsystem_id}",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}")
|
||||
def api_get_entity(entity_id: str) -> dict:
|
||||
"""Get an entity with its relationships and related entities."""
|
||||
|
||||
@@ -118,70 +118,6 @@ def system_map(project: str) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def subsystem_contents(subsystem_id: str) -> dict | None:
|
||||
"""Q-001 subsystem-scoped variant: a single subsystem and its
|
||||
direct ``CONTAINS`` children.
|
||||
|
||||
Spec: ``GET /entities/Subsystem/<id>?expand=contains`` per
|
||||
``docs/architecture/engineering-query-catalog.md`` Q-001.
|
||||
|
||||
Differs from :func:`system_map` (Q-004) which returns the
|
||||
project-wide tree. The subsystem-scoped form is what individual
|
||||
operator queries actually need: "what's inside this one subsystem?"
|
||||
rather than "show me the whole project."
|
||||
|
||||
The relationship walk uses inbound ``part_of`` edges (the inverse
|
||||
of ``CONTAINS``) so both child Components and child Subsystems
|
||||
surface uniformly. Filters to active children only — superseded
|
||||
or invalid rows do not belong in a "current contents" answer.
|
||||
|
||||
Returns:
|
||||
``{"subsystem": {id, name, project, status, description},
|
||||
"contains": [{id, entity_type, name, status}]}``
|
||||
or ``None`` when the entity does not exist or is not a subsystem.
|
||||
"""
|
||||
with get_connection() as conn:
|
||||
ss = conn.execute(
|
||||
"SELECT * FROM entities WHERE id = ?",
|
||||
(subsystem_id,),
|
||||
).fetchone()
|
||||
if ss is None or ss["entity_type"] != "subsystem":
|
||||
return None
|
||||
rows = conn.execute(
|
||||
"SELECT e.id, e.entity_type, e.name, e.status "
|
||||
"FROM relationships r "
|
||||
"JOIN entities e ON e.id = r.source_entity_id "
|
||||
"WHERE r.relationship_type = 'part_of' "
|
||||
"AND r.target_entity_id = ? "
|
||||
"AND e.status = 'active' "
|
||||
"ORDER BY e.entity_type, e.name",
|
||||
(subsystem_id,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"subsystem": {
|
||||
"id": ss["id"],
|
||||
"name": ss["name"],
|
||||
"project": ss["project"],
|
||||
"status": ss["status"],
|
||||
"description": ss["description"] or "",
|
||||
},
|
||||
"contains": [
|
||||
{
|
||||
"id": r["id"],
|
||||
# V1-A spec uses `type` per engineering-query-catalog.md Q-001;
|
||||
# `entity_type` is duplicated for parity with the rest of
|
||||
# this module's response shape (see `_entity_dict`).
|
||||
"type": r["entity_type"],
|
||||
"entity_type": r["entity_type"],
|
||||
"name": r["name"],
|
||||
"status": r["status"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def decisions_affecting(project: str, subsystem_id: str | None = None) -> dict:
|
||||
"""Q-008: decisions that affect a subsystem (or whole project).
|
||||
|
||||
|
||||
@@ -66,8 +66,6 @@ _V1_PUBLIC_PATHS = {
|
||||
"/entities/{entity_id}/invalidate",
|
||||
"/entities/{entity_id}/supersede",
|
||||
"/entities/{entity_id}/audit",
|
||||
# V1-A: Q-001 subsystem-scoped variant per engineering-query-catalog
|
||||
"/entities/Subsystem/{subsystem_id}",
|
||||
"/relationships",
|
||||
"/ingest",
|
||||
"/ingest/sources",
|
||||
|
||||
@@ -424,126 +424,6 @@ def get_memory_count_summary() -> dict:
|
||||
return summary
|
||||
|
||||
|
||||
def propose_emerging_projects(min_active: int = 10) -> list[dict]:
|
||||
"""Return live, on-demand registration proposals for unregistered projects.
|
||||
|
||||
Differs from the nightly ``scripts/detect_emerging.py`` cache (which
|
||||
is fresh once a day and lives in ``project_state.proposals``) by
|
||||
reading current SQL and the registry directly. Each proposal is
|
||||
operator-ready: a guessed ingest root, sibling labels suggested as
|
||||
aliases, and a few sample memories so the operator can sanity-check
|
||||
the bucket before committing it.
|
||||
|
||||
Args:
|
||||
min_active: minimum number of active memories required for a
|
||||
label to surface as a proposal. Defaults to 10 — anything
|
||||
smaller is too noisy to register without more signal.
|
||||
|
||||
Returns:
|
||||
list of proposal dicts, sorted by active_count desc:
|
||||
{
|
||||
"project_id": str,
|
||||
"active_count": int,
|
||||
"candidate_count": int,
|
||||
"suggested_aliases": list[str],
|
||||
"guessed_ingest_root": {"source": "vault", "subpath": ...},
|
||||
"sample_memories": [{id, content_preview, updated_at}, ...],
|
||||
}
|
||||
"""
|
||||
from atocore.projects.registry import load_project_registry
|
||||
|
||||
# Build the set of names already known to the registry (canonical + aliases),
|
||||
# lowercased. Anything in this set is "registered" and not a proposal.
|
||||
registered_names: set[str] = set()
|
||||
try:
|
||||
for project in load_project_registry():
|
||||
registered_names.add(project.project_id.lower())
|
||||
for alias in project.aliases:
|
||||
registered_names.add(alias.lower())
|
||||
except Exception:
|
||||
# Fail-open: if the registry can't load, assume nothing is
|
||||
# registered and let the proposal surface everything.
|
||||
pass
|
||||
|
||||
with get_connection() as conn:
|
||||
# Active counts per project (excluding empty/null project — that's
|
||||
# the global bucket, not a proposal candidate).
|
||||
active_rows = conn.execute(
|
||||
"SELECT project, count(*) AS c FROM memories "
|
||||
"WHERE status = 'active' AND project IS NOT NULL AND project != '' "
|
||||
"GROUP BY project"
|
||||
).fetchall()
|
||||
cand_rows = conn.execute(
|
||||
"SELECT project, count(*) AS c FROM memories "
|
||||
"WHERE status = 'candidate' AND project IS NOT NULL AND project != '' "
|
||||
"GROUP BY project"
|
||||
).fetchall()
|
||||
|
||||
cand_counts = {r["project"]: r["c"] for r in cand_rows}
|
||||
|
||||
# Filter to unregistered labels above threshold
|
||||
unregistered: list[tuple[str, int, int]] = [] # (project, active_n, candidate_n)
|
||||
for r in active_rows:
|
||||
proj = r["project"]
|
||||
if proj.lower() in registered_names:
|
||||
continue
|
||||
if r["c"] < min_active:
|
||||
continue
|
||||
unregistered.append((proj, r["c"], cand_counts.get(proj, 0)))
|
||||
|
||||
# Sibling alias detection: two unregistered labels are siblings if
|
||||
# they share a non-trivial token (length >= 4 after splitting on
|
||||
# '-' and '_'). Cheap, defensible, and the operator gets to veto.
|
||||
def _tokens(label: str) -> set[str]:
|
||||
parts = label.lower().replace("_", "-").split("-")
|
||||
return {p for p in parts if len(p) >= 4}
|
||||
|
||||
label_tokens = {label: _tokens(label) for label, _a, _c in unregistered}
|
||||
|
||||
proposals = []
|
||||
for proj, active_n, candidate_n in sorted(
|
||||
unregistered, key=lambda t: (-t[1], t[0])
|
||||
):
|
||||
siblings = [
|
||||
other
|
||||
for other in label_tokens
|
||||
if other != proj and (label_tokens[proj] & label_tokens[other])
|
||||
]
|
||||
siblings.sort()
|
||||
|
||||
# Sample memories: top 3 active by updated_at desc
|
||||
with get_connection() as conn:
|
||||
sample_rows = conn.execute(
|
||||
"SELECT id, content, updated_at FROM memories "
|
||||
"WHERE status = 'active' AND project = ? "
|
||||
"ORDER BY updated_at DESC LIMIT 3",
|
||||
(proj,),
|
||||
).fetchall()
|
||||
samples = [
|
||||
{
|
||||
"id": r["id"],
|
||||
"content_preview": (r["content"] or "")[:160],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
for r in sample_rows
|
||||
]
|
||||
|
||||
proposals.append(
|
||||
{
|
||||
"project_id": proj,
|
||||
"active_count": active_n,
|
||||
"candidate_count": candidate_n,
|
||||
"suggested_aliases": siblings,
|
||||
"guessed_ingest_root": {
|
||||
"source": "vault",
|
||||
"subpath": f"incoming/projects/{proj}/",
|
||||
},
|
||||
"sample_memories": samples,
|
||||
}
|
||||
)
|
||||
return proposals
|
||||
|
||||
|
||||
def update_memory(
|
||||
memory_id: str,
|
||||
content: str | None = None,
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
"""Wave 1.5 — live emerging-project registration proposals.
|
||||
|
||||
The nightly `scripts/detect_emerging.py` writes a stale cache to
|
||||
`project_state.proposals.unregistered_projects`. This endpoint provides
|
||||
the on-demand alternative that operators can hit before deciding which
|
||||
unregistered project to register via `/admin/projects/register-emerging`.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import atocore.config as config
|
||||
from atocore.main import app
|
||||
from atocore.memory.service import create_memory
|
||||
from atocore.models.database import init_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_data_dir, tmp_path, monkeypatch):
|
||||
"""Fresh DB + a registry holding a single registered project so we
|
||||
can prove the proposals endpoint excludes registered names."""
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"id": "p04-gigabit",
|
||||
"aliases": ["p04", "gigabit"],
|
||||
"description": "test",
|
||||
"ingest_roots": [
|
||||
{"source": "vault", "subpath": "incoming/projects/p04-gigabit"}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
|
||||
config.settings = config.Settings()
|
||||
init_db()
|
||||
yield tmp_data_dir
|
||||
|
||||
|
||||
def test_proposals_excludes_registered_project_and_its_aliases(env):
|
||||
"""Memories tagged on a registered canonical id or any of its
|
||||
aliases must not appear as a registration proposal."""
|
||||
# Registered: p04-gigabit (aliases p04, gigabit)
|
||||
for i in range(15):
|
||||
create_memory("knowledge", f"p04 fact {i}", project="p04-gigabit")
|
||||
for i in range(15):
|
||||
create_memory("knowledge", f"alias fact {i}", project="p04") # alias
|
||||
|
||||
# Unregistered, above threshold
|
||||
for i in range(12):
|
||||
create_memory("knowledge", f"apm fact {i}", project="apm")
|
||||
|
||||
client = TestClient(app)
|
||||
body = client.get("/admin/projects/proposals?min_active=10").json()
|
||||
ids = [p["project_id"] for p in body["proposals"]]
|
||||
assert "apm" in ids
|
||||
assert "p04-gigabit" not in ids
|
||||
assert "p04" not in ids
|
||||
assert "gigabit" not in ids
|
||||
|
||||
|
||||
def test_proposals_threshold_filters_low_count_labels(env):
|
||||
create_memory("knowledge", "single one-off", project="discrawl")
|
||||
for i in range(3):
|
||||
create_memory("knowledge", f"low-volume {i}", project="drill")
|
||||
for i in range(11):
|
||||
create_memory("knowledge", f"high-volume {i}", project="apm")
|
||||
|
||||
client = TestClient(app)
|
||||
proposals = client.get("/admin/projects/proposals?min_active=10").json()["proposals"]
|
||||
ids = [p["project_id"] for p in proposals]
|
||||
assert "apm" in ids
|
||||
assert "drill" not in ids
|
||||
assert "discrawl" not in ids
|
||||
|
||||
|
||||
def test_proposals_suggest_sibling_aliases_via_shared_tokens(env):
|
||||
"""Lead-space + lead-space-exploration-ltd + space-exploration-ltd
|
||||
should cluster: each proposes the others as suggested_aliases via
|
||||
shared non-trivial tokens (length >= 4)."""
|
||||
for label in ("lead-space", "lead-space-exploration-ltd", "space-exploration-ltd"):
|
||||
for i in range(11):
|
||||
create_memory("knowledge", f"{label} content {i}", project=label)
|
||||
|
||||
client = TestClient(app)
|
||||
proposals = {p["project_id"]: p for p in client.get("/admin/projects/proposals").json()["proposals"]}
|
||||
|
||||
# All three appear and each suggests at least one of the others
|
||||
for label in ("lead-space", "lead-space-exploration-ltd", "space-exploration-ltd"):
|
||||
assert label in proposals
|
||||
siblings = set(proposals[label]["suggested_aliases"])
|
||||
# Every label shares "space" (and others share "exploration"/"lead")
|
||||
# so at least one sibling must be present.
|
||||
assert siblings & {"lead-space", "lead-space-exploration-ltd", "space-exploration-ltd"} - {label}
|
||||
|
||||
|
||||
def test_proposals_short_token_does_not_match(env):
|
||||
"""Per Codex Wave 1.5 P2: previously this test only asserted apm
|
||||
and drill have empty siblings, which is trivially true because they
|
||||
share no tokens at all. The real risk is an accidental relaxation
|
||||
that lets <4-char tokens trigger clustering. Construct a setup where
|
||||
that would matter:
|
||||
- 'apm' and 'apm-fpga': only the 3-char 'apm' is shared. They must
|
||||
NOT cluster, because 'apm' is too short.
|
||||
- 'foo-fpga' and 'bar-fpga': the 4-char 'fpga' is shared. They
|
||||
MUST cluster.
|
||||
"""
|
||||
for label in ("apm", "apm-fpga", "foo-fpga", "bar-fpga"):
|
||||
for i in range(11):
|
||||
create_memory("knowledge", f"{label} fact {i}", project=label)
|
||||
|
||||
client = TestClient(app)
|
||||
proposals = {p["project_id"]: p for p in client.get("/admin/projects/proposals").json()["proposals"]}
|
||||
|
||||
# Negative: short-token match must not happen
|
||||
assert "apm-fpga" not in proposals["apm"]["suggested_aliases"], (
|
||||
"'apm' (3 chars) is below the 4-char minimum; 'apm' and 'apm-fpga' "
|
||||
"must not cluster via the 'apm' token."
|
||||
)
|
||||
|
||||
# Positive: long-token match must happen — both directions
|
||||
assert "bar-fpga" in proposals["foo-fpga"]["suggested_aliases"]
|
||||
assert "foo-fpga" in proposals["bar-fpga"]["suggested_aliases"]
|
||||
# And 'apm-fpga' clusters with the others via 'fpga'
|
||||
assert "apm-fpga" in proposals["foo-fpga"]["suggested_aliases"]
|
||||
|
||||
|
||||
def test_proposals_clustering_is_case_insensitive(env):
|
||||
"""Token comparison must be case-insensitive so labels captured
|
||||
with mixed casing still cluster. Codex Wave 1.5 P3."""
|
||||
for label in ("HydroTech-Mining", "hydrotech-split-tank"):
|
||||
for i in range(11):
|
||||
create_memory("knowledge", f"{label} fact {i}", project=label)
|
||||
|
||||
client = TestClient(app)
|
||||
proposals = {p["project_id"]: p for p in client.get("/admin/projects/proposals").json()["proposals"]}
|
||||
assert "hydrotech-split-tank" in proposals["HydroTech-Mining"]["suggested_aliases"]
|
||||
assert "HydroTech-Mining" in proposals["hydrotech-split-tank"]["suggested_aliases"]
|
||||
|
||||
|
||||
def test_proposals_registered_token_does_not_leak_into_sibling_set(env, monkeypatch):
|
||||
"""Registered project ids must be filtered BEFORE clustering so a
|
||||
registered token doesn't get suggested as an alias for an
|
||||
unregistered sibling. p04-gigabit is registered in env; an
|
||||
unregistered 'gigabit-other' must not list 'p04-gigabit' as alias."""
|
||||
for i in range(15):
|
||||
create_memory("knowledge", f"p04 fact {i}", project="p04-gigabit")
|
||||
for i in range(11):
|
||||
create_memory("knowledge", f"gigabit-other fact {i}", project="gigabit-other")
|
||||
|
||||
client = TestClient(app)
|
||||
proposals = {p["project_id"]: p for p in client.get("/admin/projects/proposals").json()["proposals"]}
|
||||
assert "p04-gigabit" not in proposals
|
||||
assert "gigabit-other" in proposals
|
||||
# And the registered name must not surface as a sibling
|
||||
assert "p04-gigabit" not in proposals["gigabit-other"]["suggested_aliases"]
|
||||
|
||||
|
||||
def test_proposals_include_sample_memories_and_guessed_root(env):
|
||||
for i in range(11):
|
||||
create_memory("knowledge", f"sample content {i}", project="apm")
|
||||
|
||||
client = TestClient(app)
|
||||
body = client.get("/admin/projects/proposals").json()
|
||||
apm = next(p for p in body["proposals"] if p["project_id"] == "apm")
|
||||
assert apm["active_count"] == 11
|
||||
assert apm["candidate_count"] == 0
|
||||
assert apm["guessed_ingest_root"] == {
|
||||
"source": "vault",
|
||||
"subpath": "incoming/projects/apm/",
|
||||
}
|
||||
assert len(apm["sample_memories"]) == 3
|
||||
for s in apm["sample_memories"]:
|
||||
assert s["id"]
|
||||
assert "sample content" in s["content_preview"]
|
||||
|
||||
|
||||
def test_proposals_count_candidates_separately(env):
|
||||
for i in range(11):
|
||||
create_memory("knowledge", f"active {i}", project="apm")
|
||||
for i in range(4):
|
||||
create_memory("knowledge", f"candidate {i}", project="apm", status="candidate")
|
||||
|
||||
client = TestClient(app)
|
||||
apm = next(
|
||||
p for p in client.get("/admin/projects/proposals").json()["proposals"]
|
||||
if p["project_id"] == "apm"
|
||||
)
|
||||
assert apm["active_count"] == 11
|
||||
assert apm["candidate_count"] == 4
|
||||
|
||||
|
||||
def test_proposals_min_active_param_validation(env):
|
||||
client = TestClient(app)
|
||||
r = client.get("/admin/projects/proposals?min_active=0")
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_proposals_sorted_by_active_count_desc(env):
|
||||
for i in range(20):
|
||||
create_memory("knowledge", f"big {i}", project="apm")
|
||||
for i in range(11):
|
||||
create_memory("knowledge", f"small {i}", project="openclaw")
|
||||
|
||||
client = TestClient(app)
|
||||
proposals = client.get("/admin/projects/proposals").json()["proposals"]
|
||||
ids = [p["project_id"] for p in proposals]
|
||||
assert ids[0] == "apm"
|
||||
assert ids[1] == "openclaw"
|
||||
@@ -1,278 +0,0 @@
|
||||
"""V1-A — minimum query slice that proves the entity model end-to-end.
|
||||
|
||||
Per ``docs/plans/engineering-v1-completion-plan.md`` Phase V1-A:
|
||||
the four pillar queries (Q-001 subsystem-scoped, Q-005, Q-006, Q-017)
|
||||
must run against a single seed graph and report the expected shapes.
|
||||
|
||||
This file is intentionally separate from ``test_engineering_queries.py``
|
||||
so the V1-A acceptance is auditable in isolation. The seed graph mirrors
|
||||
what the V1-A plan called for: one satisfying Component, one orphan
|
||||
Requirement, one supported ValidationClaim, one unsupported one, one
|
||||
Decision on a flagged Assumption.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from atocore.engineering.queries import (
|
||||
evidence_chain,
|
||||
orphan_requirements,
|
||||
requirements_for,
|
||||
risky_decisions,
|
||||
subsystem_contents,
|
||||
unsupported_claims,
|
||||
)
|
||||
from atocore.engineering.service import (
|
||||
create_entity,
|
||||
create_relationship,
|
||||
init_engineering_schema,
|
||||
invalidate_active_entity,
|
||||
)
|
||||
from atocore.main import app
|
||||
from atocore.models.database import init_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def v1a_seed(tmp_data_dir):
|
||||
"""Seed the Q-6 integration data exactly as V1-A's plan describes:
|
||||
one satisfying Component + one orphan Requirement + one Decision on
|
||||
a flagged Assumption + one supported ValidationClaim + one
|
||||
unsupported ValidationClaim, plus the Subsystem they live under."""
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
|
||||
# Subsystem (Q-001 target)
|
||||
optics = create_entity("subsystem", "Optics", project="p05-interferometer")
|
||||
|
||||
# Components — one with PART_OF the subsystem and SATISFIES a requirement,
|
||||
# one without parents (orphan_components in Q-004; not the focus here).
|
||||
primary = create_entity("component", "Primary Mirror", project="p05-interferometer")
|
||||
diverger = create_entity("component", "Diverger Lens", project="p05-interferometer")
|
||||
create_relationship(primary.id, optics.id, "part_of")
|
||||
create_relationship(diverger.id, optics.id, "part_of")
|
||||
|
||||
# Requirements — one satisfied by primary, one orphan (Q-006)
|
||||
req_satisfied = create_entity(
|
||||
"requirement", "Surface figure < 25 nm RMS", project="p05-interferometer"
|
||||
)
|
||||
req_orphan = create_entity(
|
||||
"requirement", "Calibration repeatable to lambda/20", project="p05-interferometer"
|
||||
)
|
||||
create_relationship(primary.id, req_satisfied.id, "satisfies")
|
||||
|
||||
# Decision on a flagged Assumption (Q-009 surface — not asserted in V1-A
|
||||
# acceptance but useful as background data)
|
||||
flagged_assumption = create_entity(
|
||||
"parameter",
|
||||
"Vendor lead time = 6 weeks",
|
||||
project="p05-interferometer",
|
||||
properties={"flagged": True},
|
||||
)
|
||||
risky = create_entity(
|
||||
"decision", "Pre-order CGH from external vendor", project="p05-interferometer"
|
||||
)
|
||||
create_relationship(risky.id, flagged_assumption.id, "based_on_assumption")
|
||||
|
||||
# Validation claims — one supported by a Result, one unsupported (Q-017
|
||||
# touches the supported one; Q-011 surfaces the unsupported one)
|
||||
fea_result = create_entity(
|
||||
"result", "FEA thermal sweep 2026-04", project="p05-interferometer"
|
||||
)
|
||||
claim_supported = create_entity(
|
||||
"validation_claim", "Thermal margin is adequate", project="p05-interferometer"
|
||||
)
|
||||
claim_unsupported = create_entity(
|
||||
"validation_claim", "Vibration isolation passes spec", project="p05-interferometer"
|
||||
)
|
||||
create_relationship(fea_result.id, claim_supported.id, "supports")
|
||||
|
||||
return {
|
||||
"subsystem": optics,
|
||||
"primary": primary,
|
||||
"diverger": diverger,
|
||||
"req_satisfied": req_satisfied,
|
||||
"req_orphan": req_orphan,
|
||||
"claim_supported": claim_supported,
|
||||
"claim_unsupported": claim_unsupported,
|
||||
"fea_result": fea_result,
|
||||
"risky_decision": risky,
|
||||
"flagged_assumption": flagged_assumption,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Q-001 subsystem-scoped — the V1-A code change
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_subsystem_contents_returns_spec_shape(v1a_seed):
|
||||
"""The shape must match the catalog spec
|
||||
(engineering-query-catalog.md Q-001):
|
||||
``{subsystem, contains: [{id, type, name, status}]}``.
|
||||
The implementation also emits ``entity_type`` for parity with the
|
||||
rest of this module's response style — both must be present."""
|
||||
result = subsystem_contents(v1a_seed["subsystem"].id)
|
||||
|
||||
assert result is not None
|
||||
assert set(result.keys()) == {"subsystem", "contains"}
|
||||
ss = result["subsystem"]
|
||||
assert ss["id"] == v1a_seed["subsystem"].id
|
||||
assert ss["name"] == "Optics"
|
||||
assert ss["project"] == "p05-interferometer"
|
||||
assert ss["status"] == "active"
|
||||
|
||||
contained = {c["name"] for c in result["contains"]}
|
||||
assert contained == {"Primary Mirror", "Diverger Lens"}
|
||||
for child in result["contains"]:
|
||||
# Spec requires `type`; we also include `entity_type` for parity.
|
||||
assert "type" in child
|
||||
assert "entity_type" in child
|
||||
assert child["type"] == child["entity_type"]
|
||||
assert set(child.keys()) >= {"id", "type", "entity_type", "name", "status"}
|
||||
assert child["entity_type"] == "component"
|
||||
assert child["status"] == "active"
|
||||
|
||||
|
||||
def test_subsystem_contents_returns_none_for_missing_id(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
assert subsystem_contents("no-such-id") is None
|
||||
|
||||
|
||||
def test_subsystem_contents_returns_none_when_entity_is_not_a_subsystem(v1a_seed):
|
||||
"""Calling the subsystem-scoped query against a Component must
|
||||
return None, not the component dressed up as a subsystem."""
|
||||
assert subsystem_contents(v1a_seed["primary"].id) is None
|
||||
|
||||
|
||||
def test_subsystem_contents_route_matches_spec(v1a_seed):
|
||||
"""Spec invocation: GET /entities/Subsystem/<id>?expand=contains.
|
||||
Verify the route is registered, the expand=contains path works,
|
||||
and unsupported expand values 400."""
|
||||
client = TestClient(app)
|
||||
sid = v1a_seed["subsystem"].id
|
||||
|
||||
r = client.get(f"/entities/Subsystem/{sid}?expand=contains")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["subsystem"]["id"] == sid
|
||||
names = {c["name"] for c in body["contains"]}
|
||||
assert names == {"Primary Mirror", "Diverger Lens"}
|
||||
|
||||
# Default expand is "contains" so omitting the param should also work
|
||||
r = client.get(f"/entities/Subsystem/{sid}")
|
||||
assert r.status_code == 200
|
||||
|
||||
# Unsupported expand value 400s with an informative message
|
||||
r = client.get(f"/entities/Subsystem/{sid}?expand=parents")
|
||||
assert r.status_code == 400
|
||||
assert "expand" in r.json()["detail"].lower()
|
||||
|
||||
# Missing/wrong-type subsystem 404s
|
||||
r = client.get(f"/entities/Subsystem/{v1a_seed['primary'].id}")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_subsystem_contents_v1_alias_is_present():
|
||||
"""The subsystem-scoped variant must also be reachable under /v1."""
|
||||
client = TestClient(app)
|
||||
spec = client.get("/openapi.json").json()
|
||||
assert "/v1/entities/Subsystem/{subsystem_id}" in spec["paths"]
|
||||
|
||||
|
||||
def test_subsystem_contents_v1_alias_serves_real_response(v1a_seed):
|
||||
"""Behavior, not just OpenAPI presence — catches a future
|
||||
route-copy or ordering regression (Codex V1-A P3)."""
|
||||
client = TestClient(app)
|
||||
sid = v1a_seed["subsystem"].id
|
||||
|
||||
r = client.get(f"/v1/entities/Subsystem/{sid}?expand=contains")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["subsystem"]["id"] == sid
|
||||
assert {c["name"] for c in body["contains"]} == {"Primary Mirror", "Diverger Lens"}
|
||||
|
||||
|
||||
def test_subsystem_contents_includes_child_subsystems_and_excludes_inactive(tmp_data_dir):
|
||||
"""Codex V1-A P3: lock in two intended behaviors that aren't
|
||||
obvious from the data shape:
|
||||
1. Child *Subsystems* (nested) surface in `contains` — the impl
|
||||
walks part_of irrespective of child entity_type.
|
||||
2. Children whose status is not 'active' are filtered out."""
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
|
||||
parent = create_entity("subsystem", "Parent System", project="p-nested")
|
||||
child_ss = create_entity("subsystem", "Child Subsystem", project="p-nested")
|
||||
child_comp = create_entity("component", "Child Component", project="p-nested")
|
||||
invalid_comp = create_entity("component", "Invalidated Component", project="p-nested")
|
||||
create_relationship(child_ss.id, parent.id, "part_of")
|
||||
create_relationship(child_comp.id, parent.id, "part_of")
|
||||
create_relationship(invalid_comp.id, parent.id, "part_of")
|
||||
|
||||
invalidate_active_entity(invalid_comp.id, reason="v1a regression seed")
|
||||
|
||||
result = subsystem_contents(parent.id)
|
||||
names_by_type = {(c["entity_type"], c["name"]) for c in result["contains"]}
|
||||
assert ("subsystem", "Child Subsystem") in names_by_type
|
||||
assert ("component", "Child Component") in names_by_type
|
||||
# Inactive child must not appear
|
||||
assert all(c["name"] != "Invalidated Component" for c in result["contains"])
|
||||
assert all(c["status"] == "active" for c in result["contains"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V1-A acceptance — the four pillar queries against the single seed graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_v1a_pillar_queries_run_end_to_end_against_single_seed(v1a_seed):
|
||||
"""The V1-A acceptance test. All four pillar queries must report
|
||||
the expected shape against the same seed graph. If this test fails,
|
||||
V1-A is not done — full stop. (Per the plan: "If the four pillar
|
||||
queries don't work, stopping here is cheap.")"""
|
||||
project = "p05-interferometer"
|
||||
|
||||
# Q-001 subsystem-scoped
|
||||
q1 = subsystem_contents(v1a_seed["subsystem"].id)
|
||||
assert q1 is not None
|
||||
assert {c["name"] for c in q1["contains"]} == {"Primary Mirror", "Diverger Lens"}
|
||||
|
||||
# Q-005 — requirements satisfied by Primary Mirror
|
||||
q5 = requirements_for(v1a_seed["primary"].id)
|
||||
assert q5["count"] == 1
|
||||
assert q5["requirements"][0]["name"] == "Surface figure < 25 nm RMS"
|
||||
|
||||
# Q-006 (killer correctness) — orphan requirements in the project
|
||||
q6 = orphan_requirements(project)
|
||||
orphan_names = {r["name"] for r in q6["gaps"]}
|
||||
assert "Calibration repeatable to lambda/20" in orphan_names
|
||||
assert "Surface figure < 25 nm RMS" not in orphan_names
|
||||
|
||||
# Q-017 — evidence chain for the supported ValidationClaim
|
||||
q17 = evidence_chain(v1a_seed["claim_supported"].id)
|
||||
via_supports = [edge for edge in q17["evidence_chain"] if edge["via"] == "supports"]
|
||||
assert any(edge["source_name"] == "FEA thermal sweep 2026-04" for edge in via_supports)
|
||||
|
||||
# And the unsupported claim must have an empty supports chain
|
||||
q17_unsup = evidence_chain(v1a_seed["claim_unsupported"].id)
|
||||
assert not any(edge["via"] == "supports" for edge in q17_unsup["evidence_chain"])
|
||||
|
||||
|
||||
def test_v1a_seed_also_exercises_q009_and_q011_killers(v1a_seed):
|
||||
"""Per Codex V1-A P2: the seed already includes Q-009 (decision on
|
||||
flagged assumption) and Q-011 (unsupported validation claim) data,
|
||||
so the V1-A integration test should assert those killer queries
|
||||
surface them. Otherwise the seed entities are dead weight."""
|
||||
project = "p05-interferometer"
|
||||
|
||||
q9 = risky_decisions(project)
|
||||
risky_names = {row["decision_name"] for row in q9["gaps"]}
|
||||
assert "Pre-order CGH from external vendor" in risky_names
|
||||
|
||||
q11 = unsupported_claims(project)
|
||||
unsupported_names = {row["name"] for row in q11["gaps"]}
|
||||
assert "Vibration isolation passes spec" in unsupported_names
|
||||
assert "Thermal margin is adequate" not in unsupported_names
|
||||
Reference in New Issue
Block a user