Compare commits
8 Commits
7042eaea46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 365259fde0 | |||
| 23cdb3149f | |||
| 785756fb58 | |||
| a8f4d51f06 | |||
| 848ad9db2d | |||
| b69d2c7088 | |||
| d4ee52729c | |||
| 4c7075650c |
@@ -6,26 +6,26 @@
|
|||||||
|
|
||||||
## Orientation
|
## Orientation
|
||||||
|
|
||||||
- **live_sha** (Dalidou `/health` build_sha): `d3de9f6` (verified 2026-04-25T01:01Z post trusted-state ranking deploy; status=ok)
|
- **live_sha** (Dalidou `/health` build_sha): `23cdb31` (verified 2026-04-29T17:04:05Z post V1-A deploy; status=ok)
|
||||||
- **last_updated**: 2026-04-25 by Codex (retrieval harness fully green)
|
- **last_updated**: 2026-04-29 by Claude (Wave 1 + Wave 1.5 + V1-A all squash-merged and deployed today)
|
||||||
- **main_tip**: `d3de9f6`
|
- **main_tip**: `23cdb31`
|
||||||
- **test_count**: 572 on `main`
|
- **test_count**: 605 on `main` (572 + 14 Wave 1 + 10 Wave 1.5 + 9 V1-A)
|
||||||
- **harness**: `20/20 PASS` on live Dalidou, 0 blocking failures, 0 known issues
|
- **harness**: `20/20 PASS` on live Dalidou against `23cdb31`, 0 blocking failures, 0 known issues
|
||||||
- **vectors**: 33,253
|
- **vectors**: 33,253
|
||||||
- **active_memories**: 290 (`/admin/dashboard` 2026-04-24; note integrity panel reports a separate active_memory_count=951 and needs reconciliation)
|
- **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**: 0 (triage queue drained)
|
- **candidate_memories**: 44 (live as of 2026-04-29T02:00Z; queue accumulated since the 2026-04-28 nightly autotriage)
|
||||||
- **interactions**: 951 (`/admin/dashboard` 2026-04-24)
|
- **interactions**: 1054 (claude-code 474, openclaw 576; verified `/admin/dashboard` 2026-04-29)
|
||||||
- **registered_projects**: atocore, p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, abb-space (aliased p08)
|
- **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.
|
||||||
- **project_state_entries**: 128 across registered projects (`/admin/dashboard` 2026-04-24)
|
- **project_state_entries**: 128 across registered projects (verified 2026-04-29)
|
||||||
- **entities**: 66 (up from 35 — V1-0 backfill + ongoing work; 0 open conflicts)
|
- **entities**: 66 (V1-0 backfill complete; 0 open conflicts)
|
||||||
- **off_host_backup**: `papa@192.168.86.39:/home/papa/atocore-backups/` via cron, verified
|
- **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 (NEW)** → weekly synth/lint Sundays → **retrieval harness (NEW)** → **pipeline summary (NEW)**
|
- **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)
|
- **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
|
- **wiki**: http://dalidou:8100/wiki (browse), /wiki/projects/{id}, /wiki/entities/{id}, /wiki/search
|
||||||
- **dashboard**: http://dalidou:8100/admin/dashboard (now shows pipeline health, interaction totals by client, all registered projects)
|
- **dashboard**: http://dalidou:8100/admin/dashboard (now reports SQL-aggregate counts; new fields: `memories.total`, `memories.by_status`)
|
||||||
- **active_track**: Engineering V1 Completion (started 2026-04-22). V1-0 landed (`2712c5d`). V1-A density gate CLEARED (784 active ≫ 100 target as of 2026-04-23). V1-A soak gate at day 5/~7 (F4 first run 2026-04-19; nightly clean 2026-04-19 through 2026-04-23; live harness is now fully green as of 2026-04-25). Plan: `docs/plans/engineering-v1-completion-plan.md`. Resume map: `docs/plans/v1-resume-state.md`.
|
- **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`.
|
||||||
- **last_nightly_pipeline**: `2026-04-23T03:00:20Z` — harness 17/18, triage promoted=3 rejected=7 human=0, dedup 7 clusters (1 tier1 + 6 tier2 auto-merged), graduation 30-skipped 0-graduated 0-errors, auto-triage drained the queue (0 new candidates 2026-04-22T00:52Z run)
|
- **last_nightly_pipeline**: `2026-04-28T03:00:30Z` — harness 20/20, triage promoted=1 rejected=1 human=0
|
||||||
- **open_branches**: `codex/p04-constraints-state-gap` pushed and fast-forwarded into `main` as `d3de9f6`; no active unmerged code branch for this tranche.
|
- **open_branches**: none. Wave 1 (`4c70756`), Wave 1.5 (`b69d2c7`), and V1-A (`23cdb31`) all squash-merged and deployed today; branches cleaned up.
|
||||||
|
|
||||||
## Active Plan
|
## Active Plan
|
||||||
|
|
||||||
@@ -170,6 +170,20 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
|
|||||||
|
|
||||||
## Session Log
|
## 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).
|
||||||
|
|
||||||
- **2026-04-25 Codex (p04 constraint gap closed; harness fully green)** Root-caused the remaining `p04-constraints` fixture: the `Zerodur` / `1.2` fact already existed in Trusted Project State (`requirement/key_constraints`), but project-state formatting was category/key ordered and then truncated to the 20% state budget, so contacts/decisions consumed the budget before the relevant requirement. Added query-relevance ranking for Trusted Project State entries before formatting/truncation, with regression coverage in `test_project_state_query_relevance_before_truncation`. Removed the fixture's `known_issue` lane so future p04 constraint regressions are blocking. Cleaned up a duplicate live requirement entry created during diagnosis by invalidating `requirement/mirror-blank-core-constraints`; canonical `requirement/key_constraints` remains active. Verified focused suite: 35 passed. Verified full local suite: 572 passed. Deployed `d3de9f67eaa08dfc5b2d86e8221b8c70fef266d3`; live exact p04 probe now surfaces `[REQUIREMENT] key_constraints` with `1.2` and `Zerodur`. Live retrieval harness: 20/20, 0 known issues, 0 blocking failures.
|
- **2026-04-25 Codex (p04 constraint gap closed; harness fully green)** Root-caused the remaining `p04-constraints` fixture: the `Zerodur` / `1.2` fact already existed in Trusted Project State (`requirement/key_constraints`), but project-state formatting was category/key ordered and then truncated to the 20% state budget, so contacts/decisions consumed the budget before the relevant requirement. Added query-relevance ranking for Trusted Project State entries before formatting/truncation, with regression coverage in `test_project_state_query_relevance_before_truncation`. Removed the fixture's `known_issue` lane so future p04 constraint regressions are blocking. Cleaned up a duplicate live requirement entry created during diagnosis by invalidating `requirement/mirror-blank-core-constraints`; canonical `requirement/key_constraints` remains active. Verified focused suite: 35 passed. Verified full local suite: 572 passed. Deployed `d3de9f67eaa08dfc5b2d86e8221b8c70fef266d3`; live exact p04 probe now surfaces `[REQUIREMENT] key_constraints` with `1.2` and `Zerodur`. Live retrieval harness: 20/20, 0 known issues, 0 blocking failures.
|
||||||
|
|
||||||
- **2026-04-25 Codex (project_id backfill + retrieval stabilization closed)** Merged `codex/project-id-metadata-retrieval` into `main` (`867a1ab`) and deployed to Dalidou. Took Chroma-inclusive backup `/srv/storage/atocore/backups/snapshots/20260424T154358Z`, then ran `scripts/backfill_chunk_project_ids.py` per project; populated projects `p04-gigabit`, `p05-interferometer`, `p06-polisher`, `atomizer-v2`, and `atocore` applied cleanly for 33,253 vectors total, with 0 missing/malformed and an immediate final dry-run showing 33,253 already tagged / 0 updates. Post-backfill harness exposed p06 memory-ranking misses (`Tailscale`, `encoder`), so Codex shipped `4744c69` then `a87d984 fix(memory): widen query-time context candidates`. Full local suite: 571 passed. Live `/health` reports `a87d9845a8c34395a02890f0cf22aa7a46afaf62`, vectors=33,253, sources_ready=true. Live retrieval harness: 19/20, 0 blocking failures, 1 known issue (`p04-constraints` missing `Zerodur` / `1.2`). A repeat backfill dry-run after the code-only stabilization deploy was aborted after the one-off container ran too long; the live service stayed healthy and the earlier post-apply idempotency result remains the migration acceptance record. Dalidou HTTP push credentials are still not configured; this session pushed through the Windows credential path.
|
- **2026-04-25 Codex (project_id backfill + retrieval stabilization closed)** Merged `codex/project-id-metadata-retrieval` into `main` (`867a1ab`) and deployed to Dalidou. Took Chroma-inclusive backup `/srv/storage/atocore/backups/snapshots/20260424T154358Z`, then ran `scripts/backfill_chunk_project_ids.py` per project; populated projects `p04-gigabit`, `p05-interferometer`, `p06-polisher`, `atomizer-v2`, and `atocore` applied cleanly for 33,253 vectors total, with 0 missing/malformed and an immediate final dry-run showing 33,253 already tagged / 0 updates. Post-backfill harness exposed p06 memory-ranking misses (`Tailscale`, `encoder`), so Codex shipped `4744c69` then `a87d984 fix(memory): widen query-time context candidates`. Full local suite: 571 passed. Live `/health` reports `a87d9845a8c34395a02890f0cf22aa7a46afaf62`, vectors=33,253, sources_ready=true. Live retrieval harness: 19/20, 0 blocking failures, 1 known issue (`p04-constraints` missing `Zerodur` / `1.2`). A repeat backfill dry-run after the code-only stabilization deploy was aborted after the one-off container ran too long; the live service stayed healthy and the earlier post-apply idempotency result remains the migration acceptance record. Dalidou HTTP push credentials are still not configured; this session pushed through the Windows credential path.
|
||||||
|
|||||||
@@ -198,8 +198,8 @@ where surfaces are disjoint, pauses when they collide.
|
|||||||
| Phase | Scope | Status |
|
| 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-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 | 🟡 gated — starts when soak (~2026-04-26) + density (100+ active memories) gates clear |
|
| 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 | pending V1-A |
|
| V1-B | KB-CAD + KB-FEM ingest (`POST /ingest/kb-cad/export`, `POST /ingest/kb-fem/export`) + D-2 schema docs | next |
|
||||||
| 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-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-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) |
|
| 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) |
|
||||||
|
|||||||
@@ -404,19 +404,23 @@ def process_candidate(cand, base_url, active_cache, state_cache, known_projects,
|
|||||||
known_projects, TIER1_MODEL, DEFAULT_TIMEOUT_S,
|
known_projects, TIER1_MODEL, DEFAULT_TIMEOUT_S,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Project misattribution fix: suggested_project surfaces from tier 1
|
# Project misattribution fix: suggested_project surfaces from tier 1.
|
||||||
|
# Earlier code POSTed only {"content": cand["content"]}, which left
|
||||||
|
# the project field unchanged because MemoryUpdateRequest had no
|
||||||
|
# project key and the service signature didn't accept one. Wave 1
|
||||||
|
# added project to MemoryUpdateRequest and update_memory(); this
|
||||||
|
# caller now actually applies the suggested project.
|
||||||
suggested = (v1.get("suggested_project") or "").strip()
|
suggested = (v1.get("suggested_project") or "").strip()
|
||||||
if suggested and suggested != project and suggested in known_projects:
|
if suggested and suggested != project and suggested in known_projects:
|
||||||
# Try to re-canonicalize the memory's project
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
try:
|
try:
|
||||||
import urllib.request as _ur
|
import urllib.request as _ur
|
||||||
req = _ur.Request(
|
req = _ur.Request(
|
||||||
f"{base_url}/memory/{mid}", method="PUT",
|
f"{base_url}/memory/{mid}", method="PUT",
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
data=json.dumps({"content": cand["content"]}).encode("utf-8"),
|
data=json.dumps({"project": suggested}).encode("utf-8"),
|
||||||
)
|
)
|
||||||
_ur.urlopen(req, timeout=10).read() # triggers canonicalization via update
|
_ur.urlopen(req, timeout=10).read()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
print(f" ↺ misattribution flagged: {project!r} → {suggested!r}")
|
print(f" ↺ misattribution flagged: {project!r} → {suggested!r}")
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ class MemoryUpdateRequest(BaseModel):
|
|||||||
memory_type: str | None = None
|
memory_type: str | None = None
|
||||||
domain_tags: list[str] | None = None
|
domain_tags: list[str] | None = None
|
||||||
valid_until: str | None = None
|
valid_until: str | None = None
|
||||||
|
project: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectStateSetRequest(BaseModel):
|
class ProjectStateSetRequest(BaseModel):
|
||||||
@@ -473,6 +474,33 @@ def api_register_emerging_project(req: RegisterEmergingRequest) -> dict:
|
|||||||
return result
|
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}")
|
@router.put("/projects/{project_name}")
|
||||||
def api_project_update(project_name: str, req: ProjectUpdateRequest) -> dict:
|
def api_project_update(project_name: str, req: ProjectUpdateRequest) -> dict:
|
||||||
"""Update an existing project registration."""
|
"""Update an existing project registration."""
|
||||||
@@ -636,6 +664,7 @@ def api_update_memory(memory_id: str, req: MemoryUpdateRequest) -> dict:
|
|||||||
memory_type=req.memory_type,
|
memory_type=req.memory_type,
|
||||||
domain_tags=req.domain_tags,
|
domain_tags=req.domain_tags,
|
||||||
valid_until=req.valid_until,
|
valid_until=req.valid_until,
|
||||||
|
project=req.project,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -794,33 +823,25 @@ def api_invalidate_memory(
|
|||||||
req: MemoryInvalidateRequest | None = None,
|
req: MemoryInvalidateRequest | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Retract an active memory (Issue E — active → invalid)."""
|
"""Retract an active memory (Issue E — active → invalid)."""
|
||||||
from atocore.memory.service import get_memories as _get_memories, invalidate_memory
|
from atocore.memory.service import get_memory, invalidate_memory
|
||||||
|
|
||||||
reason = req.reason if req else ""
|
reason = req.reason if req else ""
|
||||||
# Quick existence/status check for a clean 404 vs 409.
|
# Direct id lookup — earlier code used get_memories(status='active', limit=1)
|
||||||
existing = [
|
# which only saw the highest-confidence active row, so any other active
|
||||||
m for m in _get_memories(status="active", limit=1)
|
# memory would 404 here even though it existed.
|
||||||
if m.id == memory_id
|
target = get_memory(memory_id)
|
||||||
]
|
if target is None:
|
||||||
if not existing:
|
|
||||||
# Fall through to generic not-active if the id exists in another status.
|
|
||||||
all_match = [
|
|
||||||
m for m in _get_memories(status="candidate", limit=5000)
|
|
||||||
+ _get_memories(status="invalid", limit=5000)
|
|
||||||
+ _get_memories(status="superseded", limit=5000)
|
|
||||||
if m.id == memory_id
|
|
||||||
]
|
|
||||||
if all_match:
|
|
||||||
if all_match[0].status == "invalid":
|
|
||||||
return {"status": "already_invalid", "id": memory_id}
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail=(
|
|
||||||
f"Memory {memory_id} is {all_match[0].status}; "
|
|
||||||
"use /reject for candidates"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=404, detail=f"Memory not found: {memory_id}")
|
raise HTTPException(status_code=404, detail=f"Memory not found: {memory_id}")
|
||||||
|
if target.status == "invalid":
|
||||||
|
return {"status": "already_invalid", "id": memory_id}
|
||||||
|
if target.status != "active":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"Memory {memory_id} is {target.status}; "
|
||||||
|
"use /reject for candidates"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
success = invalidate_memory(memory_id, actor="api-http", reason=reason)
|
success = invalidate_memory(memory_id, actor="api-http", reason=reason)
|
||||||
if not success:
|
if not success:
|
||||||
@@ -833,15 +854,33 @@ def api_supersede_memory(
|
|||||||
memory_id: str,
|
memory_id: str,
|
||||||
req: MemorySupersedeRequest | None = None,
|
req: MemorySupersedeRequest | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Supersede an active memory (Issue E — active → superseded)."""
|
"""Supersede an active memory (Issue E — active → superseded).
|
||||||
from atocore.memory.service import supersede_memory
|
|
||||||
|
Mirrors the invalidate route's status guard: candidates and other
|
||||||
|
non-active rows must not silently flip to superseded.
|
||||||
|
"""
|
||||||
|
from atocore.memory.service import get_memory, supersede_memory
|
||||||
|
|
||||||
reason = req.reason if req else ""
|
reason = req.reason if req else ""
|
||||||
|
target = get_memory(memory_id)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Memory not found: {memory_id}")
|
||||||
|
if target.status == "superseded":
|
||||||
|
return {"status": "already_superseded", "id": memory_id}
|
||||||
|
if target.status != "active":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"Memory {memory_id} is {target.status}; "
|
||||||
|
"only active memories can be superseded"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
success = supersede_memory(memory_id, actor="api-http", reason=reason)
|
success = supersede_memory(memory_id, actor="api-http", reason=reason)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=409,
|
||||||
detail=f"Memory not found or not active: {memory_id}",
|
detail=f"Memory {memory_id} could not be superseded",
|
||||||
)
|
)
|
||||||
return {"status": "superseded", "id": memory_id}
|
return {"status": "superseded", "id": memory_id}
|
||||||
|
|
||||||
@@ -1280,16 +1319,20 @@ def api_dashboard() -> dict:
|
|||||||
health beyond the basic /health endpoint.
|
health beyond the basic /health endpoint.
|
||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
from collections import Counter
|
|
||||||
from datetime import datetime as _dt, timezone as _tz
|
from datetime import datetime as _dt, timezone as _tz
|
||||||
|
|
||||||
all_memories = get_memories(active_only=False, limit=500)
|
from atocore.memory.service import get_memory_count_summary
|
||||||
active = [m for m in all_memories if m.status == "active"]
|
|
||||||
candidates = [m for m in all_memories if m.status == "candidate"]
|
|
||||||
|
|
||||||
type_counts = dict(Counter(m.memory_type for m in active))
|
# SQL-backed counts. Earlier code derived these by sampling the top
|
||||||
project_counts = dict(Counter(m.project or "(none)" for m in active))
|
# 500 rows of get_memories() ordered by confidence — anything past
|
||||||
reinforced = [m for m in active if m.reference_count > 0]
|
# the cap was invisible, so /admin/dashboard silently undercounted
|
||||||
|
# active memories once the corpus crossed ~500 active rows.
|
||||||
|
counts = get_memory_count_summary()
|
||||||
|
active_total = counts["active"]["total"]
|
||||||
|
candidate_total = counts["by_status"].get("candidate", 0)
|
||||||
|
type_counts = counts["active"]["by_type"]
|
||||||
|
project_counts = counts["active"]["by_project"]
|
||||||
|
reinforced_total = counts["active"]["reinforced"]
|
||||||
|
|
||||||
# Interaction stats — total + by_client from DB directly
|
# Interaction stats — total + by_client from DB directly
|
||||||
interaction_stats: dict = {"most_recent": None, "total": 0, "by_client": {}}
|
interaction_stats: dict = {"most_recent": None, "total": 0, "by_client": {}}
|
||||||
@@ -1402,13 +1445,13 @@ def api_dashboard() -> dict:
|
|||||||
|
|
||||||
# Triage queue health
|
# Triage queue health
|
||||||
triage: dict = {
|
triage: dict = {
|
||||||
"pending": len(candidates),
|
"pending": candidate_total,
|
||||||
"review_url": "/admin/triage",
|
"review_url": "/admin/triage",
|
||||||
}
|
}
|
||||||
if len(candidates) > 50:
|
if candidate_total > 50:
|
||||||
triage["warning"] = f"High queue: {len(candidates)} candidates pending review."
|
triage["warning"] = f"High queue: {candidate_total} candidates pending review."
|
||||||
elif len(candidates) > 20:
|
elif candidate_total > 20:
|
||||||
triage["notice"] = f"{len(candidates)} candidates awaiting triage."
|
triage["notice"] = f"{candidate_total} candidates awaiting triage."
|
||||||
|
|
||||||
# Recent audit activity (Phase 4 V1) — last 10 mutations for operator
|
# Recent audit activity (Phase 4 V1) — last 10 mutations for operator
|
||||||
recent_audit: list[dict] = []
|
recent_audit: list[dict] = []
|
||||||
@@ -1420,11 +1463,13 @@ def api_dashboard() -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"memories": {
|
"memories": {
|
||||||
"active": len(active),
|
"active": active_total,
|
||||||
"candidates": len(candidates),
|
"candidates": candidate_total,
|
||||||
"by_type": type_counts,
|
"by_type": type_counts,
|
||||||
"by_project": project_counts,
|
"by_project": project_counts,
|
||||||
"reinforced": len(reinforced),
|
"reinforced": reinforced_total,
|
||||||
|
"by_status": counts["by_status"],
|
||||||
|
"total": counts["total"],
|
||||||
},
|
},
|
||||||
"project_state": {
|
"project_state": {
|
||||||
"counts": ps_counts,
|
"counts": ps_counts,
|
||||||
@@ -2348,6 +2393,41 @@ def api_entity_audit(entity_id: str, limit: int = 100) -> dict:
|
|||||||
return {"entity_id": entity_id, "entries": entries, "count": len(entries)}
|
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}")
|
@router.get("/entities/{entity_id}")
|
||||||
def api_get_entity(entity_id: str) -> dict:
|
def api_get_entity(entity_id: str) -> dict:
|
||||||
"""Get an entity with its relationships and related entities."""
|
"""Get an entity with its relationships and related entities."""
|
||||||
|
|||||||
@@ -118,6 +118,70 @@ def system_map(project: str) -> dict:
|
|||||||
return out
|
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:
|
def decisions_affecting(project: str, subsystem_id: str | None = None) -> dict:
|
||||||
"""Q-008: decisions that affect a subsystem (or whole project).
|
"""Q-008: decisions that affect a subsystem (or whole project).
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ _V1_PUBLIC_PATHS = {
|
|||||||
"/entities/{entity_id}/invalidate",
|
"/entities/{entity_id}/invalidate",
|
||||||
"/entities/{entity_id}/supersede",
|
"/entities/{entity_id}/supersede",
|
||||||
"/entities/{entity_id}/audit",
|
"/entities/{entity_id}/audit",
|
||||||
|
# V1-A: Q-001 subsystem-scoped variant per engineering-query-catalog
|
||||||
|
"/entities/Subsystem/{subsystem_id}",
|
||||||
"/relationships",
|
"/relationships",
|
||||||
"/ingest",
|
"/ingest",
|
||||||
"/ingest/sources",
|
"/ingest/sources",
|
||||||
|
|||||||
@@ -347,6 +347,203 @@ def get_memories(
|
|||||||
return [_row_to_memory(r) for r in rows]
|
return [_row_to_memory(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory(memory_id: str) -> Memory | None:
|
||||||
|
"""Return a single memory by id, or None if missing.
|
||||||
|
|
||||||
|
Direct id lookup (no LIMIT, no confidence ordering) — the right
|
||||||
|
primitive for routes that need to check a specific memory's status
|
||||||
|
before acting. Avoids the sampling pitfall where ``get_memories``
|
||||||
|
with a small ``limit`` could hide a target row sorted past the cap.
|
||||||
|
"""
|
||||||
|
with get_connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM memories WHERE id = ?", (memory_id,)
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_memory(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_count_summary() -> dict:
|
||||||
|
"""Aggregate memory counts straight from SQL (no sampling).
|
||||||
|
|
||||||
|
Returned shape:
|
||||||
|
{
|
||||||
|
"total": int, # all rows
|
||||||
|
"by_status": {status: int, ...}, # full table
|
||||||
|
"active": {
|
||||||
|
"total": int,
|
||||||
|
"reinforced": int, # active with reference_count > 0
|
||||||
|
"by_type": {memory_type: int, ...},
|
||||||
|
"by_project": {project_or_none: int, ...},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Distinct from ``get_memories(...)``, which is a row-fetcher with a
|
||||||
|
confidence-sorted LIMIT and is therefore not safe for counting.
|
||||||
|
"""
|
||||||
|
summary: dict = {
|
||||||
|
"total": 0,
|
||||||
|
"by_status": {},
|
||||||
|
"active": {
|
||||||
|
"total": 0,
|
||||||
|
"reinforced": 0,
|
||||||
|
"by_type": {},
|
||||||
|
"by_project": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with get_connection() as conn:
|
||||||
|
row = conn.execute("SELECT count(*) FROM memories").fetchone()
|
||||||
|
summary["total"] = row[0] if row else 0
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT status, count(*) FROM memories GROUP BY status"
|
||||||
|
).fetchall()
|
||||||
|
summary["by_status"] = {r[0]: r[1] for r in rows}
|
||||||
|
|
||||||
|
active_total = summary["by_status"].get("active", 0)
|
||||||
|
summary["active"]["total"] = active_total
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT memory_type, count(*) FROM memories "
|
||||||
|
"WHERE status = 'active' GROUP BY memory_type"
|
||||||
|
).fetchall()
|
||||||
|
summary["active"]["by_type"] = {r[0]: r[1] for r in rows}
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT COALESCE(NULLIF(project, ''), '(none)') AS project, count(*) "
|
||||||
|
"FROM memories WHERE status = 'active' GROUP BY project"
|
||||||
|
).fetchall()
|
||||||
|
summary["active"]["by_project"] = {r[0]: r[1] for r in rows}
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT count(*) FROM memories "
|
||||||
|
"WHERE status = 'active' AND reference_count > 0"
|
||||||
|
).fetchone()
|
||||||
|
summary["active"]["reinforced"] = row[0] if row else 0
|
||||||
|
|
||||||
|
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(
|
def update_memory(
|
||||||
memory_id: str,
|
memory_id: str,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
@@ -355,6 +552,7 @@ def update_memory(
|
|||||||
memory_type: str | None = None,
|
memory_type: str | None = None,
|
||||||
domain_tags: list[str] | None = None,
|
domain_tags: list[str] | None = None,
|
||||||
valid_until: str | None = None,
|
valid_until: str | None = None,
|
||||||
|
project: str | None = None,
|
||||||
actor: str = "api",
|
actor: str = "api",
|
||||||
note: str = "",
|
note: str = "",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -368,6 +566,10 @@ def update_memory(
|
|||||||
|
|
||||||
next_content = content if content is not None else existing["content"]
|
next_content = content if content is not None else existing["content"]
|
||||||
next_status = status if status is not None else existing["status"]
|
next_status = status if status is not None else existing["status"]
|
||||||
|
next_project = (
|
||||||
|
resolve_project_name(project) if project is not None
|
||||||
|
else (existing["project"] or "")
|
||||||
|
)
|
||||||
if confidence is not None:
|
if confidence is not None:
|
||||||
_validate_confidence(confidence)
|
_validate_confidence(confidence)
|
||||||
|
|
||||||
@@ -375,7 +577,7 @@ def update_memory(
|
|||||||
duplicate = conn.execute(
|
duplicate = conn.execute(
|
||||||
"SELECT id FROM memories "
|
"SELECT id FROM memories "
|
||||||
"WHERE memory_type = ? AND content = ? AND project = ? AND status = 'active' AND id != ?",
|
"WHERE memory_type = ? AND content = ? AND project = ? AND status = 'active' AND id != ?",
|
||||||
(existing["memory_type"], next_content, existing["project"] or "", memory_id),
|
(existing["memory_type"], next_content, next_project, memory_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if duplicate:
|
if duplicate:
|
||||||
raise ValueError("Update would create a duplicate active memory")
|
raise ValueError("Update would create a duplicate active memory")
|
||||||
@@ -386,6 +588,7 @@ def update_memory(
|
|||||||
"status": existing["status"],
|
"status": existing["status"],
|
||||||
"confidence": existing["confidence"],
|
"confidence": existing["confidence"],
|
||||||
"memory_type": existing["memory_type"],
|
"memory_type": existing["memory_type"],
|
||||||
|
"project": existing["project"] or "",
|
||||||
}
|
}
|
||||||
after_snapshot = dict(before_snapshot)
|
after_snapshot = dict(before_snapshot)
|
||||||
|
|
||||||
@@ -422,6 +625,10 @@ def update_memory(
|
|||||||
updates.append("valid_until = ?")
|
updates.append("valid_until = ?")
|
||||||
params.append(vu)
|
params.append(vu)
|
||||||
after_snapshot["valid_until"] = vu or ""
|
after_snapshot["valid_until"] = vu or ""
|
||||||
|
if project is not None:
|
||||||
|
updates.append("project = ?")
|
||||||
|
params.append(next_project)
|
||||||
|
after_snapshot["project"] = next_project
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
return False
|
return False
|
||||||
|
|||||||
217
tests/test_emerging_project_proposals.py
Normal file
217
tests/test_emerging_project_proposals.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"""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"
|
||||||
@@ -192,3 +192,120 @@ def test_v1_aliases_present(env):
|
|||||||
"/v1/memory/{memory_id}/supersede",
|
"/v1/memory/{memory_id}/supersede",
|
||||||
):
|
):
|
||||||
assert p in paths, f"{p} missing"
|
assert p in paths, f"{p} missing"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Wave 1 (2026-04-29) — invalidation route used to do
|
||||||
|
# `_get_memories(status='active', limit=1)` and look for the target id
|
||||||
|
# inside that single highest-confidence row, so any active memory
|
||||||
|
# outside slot 0 fell through as 404. Direct id lookup fixes it.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_invalidate_finds_active_memory_outside_top_one(env):
|
||||||
|
"""An active memory not at the top of the confidence sort must still
|
||||||
|
be invalidatable via POST /memory/{id}/invalidate."""
|
||||||
|
high = create_memory(
|
||||||
|
memory_type="knowledge",
|
||||||
|
content="high-confidence top row",
|
||||||
|
confidence=0.99,
|
||||||
|
)
|
||||||
|
low = create_memory(
|
||||||
|
memory_type="knowledge",
|
||||||
|
content="lower-confidence target",
|
||||||
|
confidence=0.55,
|
||||||
|
)
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post(f"/memory/{low.id}/invalidate", json={"reason": "wave1 regression"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["status"] == "invalidated"
|
||||||
|
# And confirm the high-confidence row is untouched
|
||||||
|
assert _get_memory(high.id).status == "active"
|
||||||
|
assert _get_memory(low.id).status == "invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_invalidate_already_invalid_is_idempotent(env):
|
||||||
|
m = create_memory(memory_type="knowledge", content="already invalid")
|
||||||
|
client = TestClient(app)
|
||||||
|
r1 = client.post(f"/memory/{m.id}/invalidate", json={"reason": "first"})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
r2 = client.post(f"/memory/{m.id}/invalidate", json={"reason": "again"})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["status"] == "already_invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_invalidate_candidate_returns_409(env):
|
||||||
|
m = create_memory(
|
||||||
|
memory_type="knowledge", content="candidate route", status="candidate"
|
||||||
|
)
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post(f"/memory/{m.id}/invalidate", json={"reason": "wrong route"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_invalidate_unknown_id_is_404(env):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post("/memory/no-such-id/invalidate", json={"reason": "ghost"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_supersede_candidate_returns_409(env):
|
||||||
|
"""Mirror of the invalidate guard: candidates must not silently flip
|
||||||
|
to superseded via the active-only supersede route."""
|
||||||
|
m = create_memory(
|
||||||
|
memory_type="knowledge", content="candidate target", status="candidate"
|
||||||
|
)
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post(f"/memory/{m.id}/supersede", json={"reason": "wrong route"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
# Row should still be a candidate
|
||||||
|
assert _get_memory(m.id).status == "candidate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_supersede_already_superseded_is_idempotent(env):
|
||||||
|
m = create_memory(memory_type="knowledge", content="will be superseded")
|
||||||
|
client = TestClient(app)
|
||||||
|
r1 = client.post(f"/memory/{m.id}/supersede", json={"reason": "first"})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
r2 = client.post(f"/memory/{m.id}/supersede", json={"reason": "again"})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["status"] == "already_superseded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_supersede_unknown_id_is_404(env):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post("/memory/no-such-id/supersede", json={"reason": "ghost"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_dashboard_active_count_matches_full_table(env):
|
||||||
|
"""/admin/dashboard memories.active must match the SQL aggregate even
|
||||||
|
when there are more active memories than the legacy sample limit (500).
|
||||||
|
|
||||||
|
This guards the Codex finding that the dashboard was deriving counts
|
||||||
|
from a confidence-sorted limit=500 fetch, hiding rows past the cap.
|
||||||
|
We don't need 500 rows in the test — a small corpus that exercises
|
||||||
|
the SQL-aggregate path is enough; the integrity-vs-dashboard equality
|
||||||
|
is the invariant being asserted.
|
||||||
|
"""
|
||||||
|
# Mix of statuses to exercise the by_status aggregate
|
||||||
|
create_memory(memory_type="knowledge", content="a")
|
||||||
|
create_memory(memory_type="knowledge", content="b", project="p06-polisher")
|
||||||
|
create_memory(memory_type="project", content="c-cand", status="candidate")
|
||||||
|
cand = create_memory(memory_type="project", content="d-cand", status="candidate")
|
||||||
|
# Invalidate one to seed an "invalid" bucket
|
||||||
|
from atocore.memory.service import invalidate_memory
|
||||||
|
target_id = cand.id
|
||||||
|
# Promote it first via direct DB so invalidate does flip a candidate
|
||||||
|
# to invalid via the service path (mirrors actual API trajectory).
|
||||||
|
invalidate_memory(target_id)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
dash = client.get("/admin/dashboard").json()
|
||||||
|
assert dash["memories"]["active"] == 2
|
||||||
|
assert dash["memories"]["candidates"] == 1
|
||||||
|
assert dash["memories"]["by_status"]["invalid"] == 1
|
||||||
|
assert dash["memories"]["total"] == 4
|
||||||
|
assert dash["memories"]["by_project"].get("p06-polisher") == 1
|
||||||
|
# "(none)" bucket is the COALESCE label for empty/null project
|
||||||
|
assert "(none)" in dash["memories"]["by_project"]
|
||||||
|
|||||||
@@ -575,3 +575,121 @@ def test_expire_stale_candidates_keeps_reinforced(isolated_db):
|
|||||||
assert mid not in expired
|
assert mid not in expired
|
||||||
mem = _get_memory_by_id(mid)
|
mem = _get_memory_by_id(mid)
|
||||||
assert mem["status"] == "candidate"
|
assert mem["status"] == "candidate"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Wave 1 (2026-04-29) — counts come from SQL, not from the top-N sample.
|
||||||
|
# Exposed by Codex audit when prod /admin/dashboard reported 315 active
|
||||||
|
# while /admin/integrity-check reported 1091. The dashboard was building
|
||||||
|
# its counts from a confidence-sorted limit=500 fetch.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_memory_count_summary_returns_full_table_aggregates(isolated_db):
|
||||||
|
"""Counts come from SQL aggregates, not a sampled fetch."""
|
||||||
|
from atocore.memory.service import (
|
||||||
|
create_memory,
|
||||||
|
get_memory_count_summary,
|
||||||
|
invalidate_memory,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create more rows than any reasonable sampling LIMIT so any
|
||||||
|
# LIMIT-based counter would visibly disagree with reality.
|
||||||
|
for i in range(120):
|
||||||
|
create_memory(
|
||||||
|
"knowledge",
|
||||||
|
f"fact-{i}",
|
||||||
|
project="p04-gigabit",
|
||||||
|
confidence=0.9,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
for i in range(7):
|
||||||
|
create_memory("knowledge", f"cand-{i}", status="candidate")
|
||||||
|
invalid_obj = create_memory("knowledge", "to-invalidate", status="active")
|
||||||
|
invalidate_memory(invalid_obj.id)
|
||||||
|
|
||||||
|
summary = get_memory_count_summary()
|
||||||
|
assert summary["total"] == 120 + 7 + 1
|
||||||
|
assert summary["by_status"]["active"] == 120
|
||||||
|
assert summary["by_status"]["candidate"] == 7
|
||||||
|
assert summary["by_status"]["invalid"] == 1
|
||||||
|
assert summary["active"]["total"] == 120
|
||||||
|
assert summary["active"]["by_type"] == {"knowledge": 120}
|
||||||
|
assert summary["active"]["by_project"] == {"p04-gigabit": 120}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_memory_returns_single_row_or_none(isolated_db):
|
||||||
|
from atocore.memory.service import create_memory, get_memory
|
||||||
|
|
||||||
|
mem = create_memory("knowledge", "single-row test")
|
||||||
|
fetched = get_memory(mem.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.id == mem.id
|
||||||
|
assert get_memory("non-existent-id") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_memory_can_change_project_with_canonicalization(
|
||||||
|
isolated_db, project_registry
|
||||||
|
):
|
||||||
|
"""update_memory(project=...) canonicalizes aliases and writes audit."""
|
||||||
|
project_registry(("p04-gigabit", ("p04", "gigabit")))
|
||||||
|
from atocore.memory.service import (
|
||||||
|
create_memory,
|
||||||
|
get_memory,
|
||||||
|
get_memory_audit,
|
||||||
|
update_memory,
|
||||||
|
)
|
||||||
|
|
||||||
|
mem = create_memory("knowledge", "retargetable fact", project="atocore")
|
||||||
|
ok = update_memory(mem.id, project="p04") # alias
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
refreshed = get_memory(mem.id)
|
||||||
|
assert refreshed.project == "p04-gigabit" # canonical, not "p04"
|
||||||
|
|
||||||
|
audit_rows = get_memory_audit(mem.id, limit=10)
|
||||||
|
update_rows = [r for r in audit_rows if r.get("action") == "updated"]
|
||||||
|
assert update_rows, f"expected an updated audit row, got {audit_rows}"
|
||||||
|
head = update_rows[0]
|
||||||
|
assert head["before"]["project"] == "atocore"
|
||||||
|
assert head["after"]["project"] == "p04-gigabit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_memory_project_unchanged_when_not_passed(isolated_db):
|
||||||
|
from atocore.memory.service import create_memory, get_memory, update_memory
|
||||||
|
|
||||||
|
mem = create_memory("knowledge", "untouched project", project="p06-polisher")
|
||||||
|
update_memory(mem.id, content="edited content")
|
||||||
|
assert get_memory(mem.id).project == "p06-polisher"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_memory_to_empty_project_detects_global_duplicate(isolated_db):
|
||||||
|
"""Codex P3: when retargeting to project='' (global), the duplicate
|
||||||
|
check must scope to the new project. If a global active memory with
|
||||||
|
the same content already exists, the update must raise."""
|
||||||
|
import pytest as _pytest
|
||||||
|
from atocore.memory.service import create_memory, update_memory
|
||||||
|
|
||||||
|
create_memory("knowledge", "shared global fact", project="")
|
||||||
|
scoped = create_memory("knowledge", "shared global fact", project="p04-gigabit")
|
||||||
|
|
||||||
|
with _pytest.raises(ValueError, match="duplicate active memory"):
|
||||||
|
update_memory(scoped.id, project="")
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_triage_suggested_project_put_body_uses_project_key():
|
||||||
|
"""Regression: the auto_triage caller used to PUT {"content": ...}
|
||||||
|
which silently dropped the suggested project change. The fix sends
|
||||||
|
{"project": suggested}. Inspect the script source so we don't have
|
||||||
|
to spin up a live triage run."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
src = Path(__file__).resolve().parents[1] / "scripts" / "auto_triage.py"
|
||||||
|
text = src.read_text(encoding="utf-8")
|
||||||
|
# The block that PUTs to /memory/{mid} for a suggested_project fix
|
||||||
|
assert 'json.dumps({"project": suggested})' in text, (
|
||||||
|
"auto_triage.py must PUT {\"project\": suggested} so the "
|
||||||
|
"suggested-project correction actually applies. See Wave 1."
|
||||||
|
)
|
||||||
|
# And must not be back to the old shape
|
||||||
|
assert 'json.dumps({"content": cand["content"]})' not in text
|
||||||
|
|||||||
278
tests/test_v1_a_pillar_queries.py
Normal file
278
tests/test_v1_a_pillar_queries.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
"""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