From 4358c290347ab94fe208aff650af7babba9080af Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Thu, 23 Apr 2026 17:26:33 +0900 Subject: [PATCH 01/10] spec: stabilize topology analysis surface --- .../case.yaml | 21 + .../events.jsonl | 41 ++ docs/README.ja.md | 11 +- docs/README.md | 11 +- .../0006-topology-projections-and-betti-v1.md | 27 +- docs/spec/05-cli.md | 10 +- tests/analysis-structure.test.ts | 47 ++ .../analysis-eval-manifest.fixture.json | 73 ++ ...lysis-eval.topology-edge-cases.events.json | 664 ++++++++++++++++++ ...structure-analysis-edge-cases.fixture.json | 201 ++++++ tests/helpers/analysis-golden.ts | 2 + 11 files changed, 1103 insertions(+), 5 deletions(-) create mode 100644 .casegraph/cases/issue-9-topology-analysis-surface/case.yaml create mode 100644 .casegraph/cases/issue-9-topology-analysis-surface/events.jsonl create mode 100644 tests/fixtures/analysis-eval.topology-edge-cases.events.json create mode 100644 tests/fixtures/structure-analysis-edge-cases.fixture.json diff --git a/.casegraph/cases/issue-9-topology-analysis-surface/case.yaml b/.casegraph/cases/issue-9-topology-analysis-surface/case.yaml new file mode 100644 index 0000000..9225d0a --- /dev/null +++ b/.casegraph/cases/issue-9-topology-analysis-surface/case.yaml @@ -0,0 +1,21 @@ +case_id: issue-9-topology-analysis-surface +title: "Issue #9: Stabilize Betti-0/1 topology analysis surface" +description: "Stabilize the Betti-0/1 topology substrate so product-facing + structural analyses can depend on it. Done means: projection semantics for + hard_unresolved and hard_goal_scope(goal_node_id) are explicit; the normalized + simple-undirected unresolved-hard graph contract is consistent across + topology, cycles, components, bridges, cutpoints, and fragility; golden/eval + coverage proves forests, simple cycles, figure-eight cycles, self-loops, + multi-edges, empty goal scopes, and goal-scoped projections; docs and exports + keep raw topology experimental and do not promote a stable cg analyze topology + command. Out of scope: a stable raw topology CLI, temporal topology APIs, and + unrelated analysis surfaces." +state: closed +labels: [] +metadata: {} +extensions: {} +created_at: 2026-04-23T07:49:56.178Z +updated_at: 2026-04-23T08:23:56.270Z +case_revision: + current: 41 + last_event_id: 01KPWQ0HKG6J9ATBWY2HPKVJ7D diff --git a/.casegraph/cases/issue-9-topology-analysis-surface/events.jsonl b/.casegraph/cases/issue-9-topology-analysis-surface/events.jsonl new file mode 100644 index 0000000..5a504fa --- /dev/null +++ b/.casegraph/cases/issue-9-topology-analysis-surface/events.jsonl @@ -0,0 +1,41 @@ +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:49:56.178Z","type":"case.created","source":"cli","command_id":"01KPWN29AM81E9H14M5EGHE0Z4","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"revision_hint":1,"payload":{"case":{"case_id":"issue-9-topology-analysis-surface","title":"Issue #9: Stabilize Betti-0/1 topology analysis surface","description":"Stabilize the Betti-0/1 topology substrate so product-facing structural analyses can depend on it. Done means: projection semantics for hard_unresolved and hard_goal_scope(goal_node_id) are explicit; the normalized simple-undirected unresolved-hard graph contract is consistent across topology, cycles, components, bridges, cutpoints, and fragility; golden/eval coverage proves forests, simple cycles, figure-eight cycles, self-loops, multi-edges, empty goal scopes, and goal-scoped projections; docs and exports keep raw topology experimental and do not promote a stable cg analyze topology command. Out of scope: a stable raw topology CLI, temporal topology APIs, and unrelated analysis surfaces.","state":"open","labels":[],"metadata":{},"extensions":{},"created_at":"2026-04-23T07:49:56.178Z","updated_at":"2026-04-23T07:49:56.178Z","case_revision":{"current":0,"last_event_id":null}}},"event_id":"01KPWN29APGHC5FKT1GV6KXWBQ","spec_version":"0.1-draft"} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:04.736Z","type":"node.added","source":"cli","command_id":"01KPWN2HP0WFSR7WSKPQ8ZHX1G","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"goal_stabilize_topology_surface","kind":"goal","title":"Stabilize the topology substrate for structural analyses","description":"Done means supported semantics for hard_unresolved and hard_goal_scope(goal_node_id) are explicit, normalization rules for self-loops and multi-edges are fixed, cycles/components/bridges/cutpoints/fragility share the same substrate, and docs plus verification keep raw topology experimental instead of promoting a stable cg analyze topology command. Inputs: GitHub issue #9. Out of scope: stable raw topology CLI and temporal topology APIs.","state":"todo","labels":[],"acceptance":[],"metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:04.736Z","updated_at":"2026-04-23T07:50:04.736Z"}},"event_id":"01KPWN2HP0S5G2NGPB5XSFR38F","spec_version":"0.1-draft","revision_hint":2} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:08.671Z","type":"node.added","source":"cli","command_id":"01KPWN2NGZ2VBXKSNSKKDZ8W5K","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"task_define_topology_contract","kind":"task","title":"Pin topology projection semantics and invariants","description":"Record the promoted contract that the stable structural analyses depend on: hard_unresolved and hard_goal_scope(goal_node_id) semantics, simple-undirected unresolved-hard projection shape, self-loop handling, multi-edge normalization, empty goal-scope warning behavior, and the boundary between experimental raw topology and user-facing analyses. Artifacts: docs/adr/0006-topology-projections-and-betti-v1.md, docs/README.md, docs/README.ja.md, docs/spec/05-cli.md, and checkpoint evidence on this case. Proof: evidence that lists the agreed invariants, affected artifacts, and the intended API/CLI boundary.","state":"todo","labels":[],"acceptance":[],"metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:08.671Z","updated_at":"2026-04-23T07:50:08.671Z"}},"event_id":"01KPWN2NGZWHVTPENZTM9EKNAQ","spec_version":"0.1-draft","revision_hint":3} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:12.348Z","type":"node.added","source":"cli","command_id":"01KPWN2S3WMYZ8S1B7AVDM7PBE","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"task_stabilize_topology_substrate","kind":"task","title":"Stabilize the shared topology substrate and exports","description":"Implement the contract in the shared topology helpers and intentional exports so cycles, components, bridges, cutpoints, and fragility all consume the same normalized substrate without promoting raw topology to a stable CLI surface. Artifacts: packages/kernel/src/analysis-topology*.ts, packages/kernel/src/analysis-*.ts that share the substrate, and packages/kernel/package.json or experimental entrypoints if export boundaries change. Proof: pnpm test -- tests/analysis-structure.test.ts tests/cli.test.ts, plus a build check if the export surface changes.","state":"todo","labels":[],"acceptance":[],"metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:12.348Z","updated_at":"2026-04-23T07:50:12.348Z"}},"event_id":"01KPWN2S3WFT0EV26104T5FAZE","spec_version":"0.1-draft","revision_hint":4} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:16.087Z","type":"node.added","source":"cli","command_id":"01KPWN2WRR3C6NVNKTXR8VS0YA","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"task_expand_topology_corpus","kind":"task","title":"Expand topology golden and eval coverage","description":"Add or update the fixtures and manifest queries that prove disconnected forests, simple cycles, figure-eight cycles, self-loops, multi-edge normalization, empty goal scopes, and goal-scoped projections. Keep topology invariants aligned with the promoted substrate and ensure the structural analyses still share it. Artifacts: tests/fixtures/analysis-eval-manifest.fixture.json, relevant tests/fixtures/analysis-eval.*.json files, tests/helpers/analysis-eval.ts, and any golden fixtures needed for topology coverage. Proof: pnpm test:analysis-golden.","state":"todo","labels":[],"acceptance":[],"metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:16.087Z","updated_at":"2026-04-23T07:50:16.087Z"}},"event_id":"01KPWN2WRRN5EAV8DG2113GV4R","spec_version":"0.1-draft","revision_hint":5} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:19.386Z","type":"node.added","source":"cli","command_id":"01KPWN2ZZVJMKZEMX4S9P4NGD2","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"task_verify_topology_delivery","kind":"task","title":"Verify topology behavior and closure evidence","description":"Run the acceptance checks for the stabilized topology behavior, confirm the docs still distinguish experimental raw topology from the user-facing analysis surfaces, and capture the final evidence needed to close issue #9. Artifacts: verification evidence on this case and any last doc touch-ups in docs/README*.md or docs/spec/05-cli.md if the checks expose drift. Proof: pnpm test:analysis-golden and pnpm test:analysis-eval.","state":"todo","labels":[],"acceptance":[],"metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:19.386Z","updated_at":"2026-04-23T07:50:19.386Z"}},"event_id":"01KPWN2ZZVJPXSYGW9SQ29NE9A","spec_version":"0.1-draft","revision_hint":6} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:27.390Z","type":"edge.added","source":"cli","command_id":"01KPWN37SZVX6RGGW0NFD1J7GF","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"edge":{"edge_id":"edge_define_contract_to_goal","type":"contributes_to","source_id":"task_define_topology_contract","target_id":"goal_stabilize_topology_surface","metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:27.390Z"}},"event_id":"01KPWN37SZJ21NG7YDY1YGG5EX","spec_version":"0.1-draft","revision_hint":7} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:30.352Z","type":"edge.added","source":"cli","command_id":"01KPWN3APHXHWQFD19S4ZE5C51","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"edge":{"edge_id":"edge_stabilize_substrate_to_goal","type":"contributes_to","source_id":"task_stabilize_topology_substrate","target_id":"goal_stabilize_topology_surface","metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:30.352Z"}},"event_id":"01KPWN3APHM4BFSE72WA1MQFBK","spec_version":"0.1-draft","revision_hint":8} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:33.329Z","type":"edge.added","source":"cli","command_id":"01KPWN3DKHB2SJVDQEGM5VSY3B","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"edge":{"edge_id":"edge_expand_corpus_to_goal","type":"contributes_to","source_id":"task_expand_topology_corpus","target_id":"goal_stabilize_topology_surface","metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:33.329Z"}},"event_id":"01KPWN3DKHKBJ3W1KJHFX7ZMBJ","spec_version":"0.1-draft","revision_hint":9} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:37.074Z","type":"edge.added","source":"cli","command_id":"01KPWN3H8K11R4B1VE85V7GPYR","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"edge":{"edge_id":"edge_verify_delivery_to_goal","type":"contributes_to","source_id":"task_verify_topology_delivery","target_id":"goal_stabilize_topology_surface","metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:37.074Z"}},"event_id":"01KPWN3H8KJQRYC8QG5NF9BT3S","spec_version":"0.1-draft","revision_hint":10} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:40.800Z","type":"edge.added","source":"cli","command_id":"01KPWN3MX00VQ8NMF3WHFGZ1DK","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"edge":{"edge_id":"edge_stabilize_after_contract","type":"depends_on","source_id":"task_stabilize_topology_substrate","target_id":"task_define_topology_contract","metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:40.800Z"}},"event_id":"01KPWN3MX1AV6TXQWGFNRTKY7W","spec_version":"0.1-draft","revision_hint":11} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:45.025Z","type":"edge.added","source":"cli","command_id":"01KPWN3S11G0SNMHXTSTY8THTE","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"edge":{"edge_id":"edge_expand_after_stabilize","type":"depends_on","source_id":"task_expand_topology_corpus","target_id":"task_stabilize_topology_substrate","metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:45.025Z"}},"event_id":"01KPWN3S119MGFAPPPMY26MZSC","spec_version":"0.1-draft","revision_hint":12} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:47.383Z","type":"edge.added","source":"cli","command_id":"01KPWN3VARXT1AX5PSVTZZZPZN","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"edge":{"edge_id":"edge_verify_after_corpus","type":"depends_on","source_id":"task_verify_topology_delivery","target_id":"task_expand_topology_corpus","metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:47.383Z"}},"event_id":"01KPWN3VAREDCWPXHA1WF1NPK6","spec_version":"0.1-draft","revision_hint":13} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:50:52.705Z","type":"evidence.attached","source":"cli","command_id":"01KPWN40H2Y9NZQAKF3QH6480Q","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"evidence_issue_9_scope","title":"Issue #9 scope and acceptance snapshot","description":"Context: raw Betti-0/1 topology exists today as experimental kernel machinery over hard_unresolved and hard_goal_scope projections. Goal: harden the one-dimensional topology layer enough that product-facing structural analyses can depend on it without exposing a stable raw topology CLI. Required scope: confirm projection semantics, keep the graph form as a simple undirected projection of unresolved hard dependencies, stabilize invariants for beta_0, beta_1, component summaries, cycle witnesses, warnings, self-loop handling, and multi-edge normalization, and keep cycles/components/bridges/cutpoints/fragility on the same substrate. Acceptance: docs distinguish raw topology internals from user-facing surfaces; topology edge-case fixtures cover forests, simple cycles, figure-eight cycles, self-loops, multi-edges, empty goal scopes, and goal-scoped projections; pnpm test:analysis-golden and pnpm test:analysis-eval cover the promoted behavior; public API exposure remains intentional and tested.","state":"done","labels":[],"acceptance":[],"metadata":{},"extensions":{},"kind":"evidence","created_at":"2026-04-23T07:50:52.705Z","updated_at":"2026-04-23T07:50:52.705Z"},"verifies_edge":{"edge_id":"01KPWN40H2KMJPPEY33F8W9RXE","type":"verifies","source_id":"evidence_issue_9_scope","target_id":"goal_stabilize_topology_surface","metadata":{},"extensions":{},"created_at":"2026-04-23T07:50:52.705Z"},"attachment":{"attachment_id":"01KPWN40H247142NRKEV75P357","evidence_node_id":"evidence_issue_9_scope","storage_mode":"url","path_or_url":"https://github.com/CAPHTECH/casegraph/issues/9","sha256":null,"mime_type":null,"size_bytes":null,"created_at":"2026-04-23T07:50:52.705Z"}},"event_id":"01KPWN40H2A8RM3MCCT36RHPJP","spec_version":"0.1-draft","revision_hint":14} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:55:40.190Z","type":"node.added","source":"cli","command_id":"01KPWNCS8ZS2HS7P60MDTHRQSX","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"decision_quality_first_structure","kind":"decision","title":"Choose a quality-first implementation cut","description":"Chosen structure: freeze invariants first, keep the code change as one minimal kernel slice, then add regression guards, then run the full acceptance gate. Why: this minimizes bug surface by avoiding parallel implementation churn and by making the proof obligations explicit before close. Rejected alternative: spread the implementation across several concurrent code tasks or fold verification into the change task where regressions can hide.","state":"todo","labels":[],"acceptance":[],"metadata":{},"extensions":{},"created_at":"2026-04-23T07:55:40.190Z","updated_at":"2026-04-23T07:55:40.190Z"}},"event_id":"01KPWNCS8ZAVYVBEZM8WZA10TF","spec_version":"0.1-draft","revision_hint":15} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:55:42.529Z","type":"node.state_changed","source":"cli","command_id":"01KPWNCVJ1GHB6ZP79473G4MP2","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"decision_quality_first_structure","state":"done","metadata":{"decision_result":"Freeze invariants, apply one minimal substrate change, then lock regressions and run the full acceptance gate."}},"event_id":"01KPWNCVJ22E6R1G4DW7QP61DC","spec_version":"0.1-draft","revision_hint":16} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:55:46.844Z","type":"node.updated","source":"cli","command_id":"01KPWNCZRW93J6K8YXNJTAY5QY","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_define_topology_contract","changes":{"title":"Freeze topology invariants before changing code","description":"Freeze the behavior before editing kernel code. Capture the promoted invariants for hard_unresolved and hard_goal_scope(goal_node_id), simple-undirected unresolved-hard normalization, self-loop and multi-edge treatment, empty goal-scope warnings, and the API boundary between experimental raw topology and user-facing analyses. This task is proof-first, not implementation-first. Artifacts: docs/adr/0006-topology-projections-and-betti-v1.md, docs/README.md, docs/README.ja.md, docs/spec/05-cli.md, and checkpoint evidence. Proof: evidence with an invariant matrix, risky edge cases, and the exact kernel surfaces the implementation task may touch."}},"event_id":"01KPWNCZRWPDGGB26W58BR8ACG","spec_version":"0.1-draft","revision_hint":17} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:55:50.265Z","type":"node.updated","source":"cli","command_id":"01KPWND33T1M2Y1H4M8P1XNXS2","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_stabilize_topology_substrate","changes":{"title":"Apply the minimal shared topology change","description":"Apply the smallest shared kernel change that satisfies the frozen contract. Keep implementation in one slice centered on the shared topology helpers and only the consumers that must follow. Avoid mixing fixture expansion or broad doc rewrites into this task. Artifacts: packages/kernel/src/analysis-topology*.ts and only the directly dependent analysis modules or export surfaces that must change. Proof: targeted pnpm test -- tests/analysis-structure.test.ts tests/cli.test.ts, plus checkpoint evidence listing the touched files and remaining risk."}},"event_id":"01KPWND33TPC1F8JSCN0PHFWJN","spec_version":"0.1-draft","revision_hint":18} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:55:54.267Z","type":"node.updated","source":"cli","command_id":"01KPWND70W3MWGZ21K02P15H16","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_expand_topology_corpus","changes":{"title":"Lock regressions with topology edge-case coverage","description":"Add the quality lock that makes regressions expensive: golden fixtures and eval-manifest coverage for disconnected forests, simple cycles, figure-eight cycles, self-loops, multi-edge normalization, empty goal scopes, and goal-scoped projections. Reuse the same corpora where possible so cycles, components, bridges, cutpoints, and fragility stay aligned with the topology invariants. Artifacts: tests/fixtures/analysis-eval-manifest.fixture.json, relevant tests/fixtures/analysis-eval.*.json files, tests/helpers/analysis-eval.ts, and any topology-specific golden fixtures. Proof: pnpm test:analysis-golden."}},"event_id":"01KPWND70WPCGJNSP2CE9CQQ6Y","spec_version":"0.1-draft","revision_hint":19} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:55:57.880Z","type":"node.updated","source":"cli","command_id":"01KPWNDAHSYYK7VXF2CAP3EFXC","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_verify_topology_delivery","changes":{"title":"Run the full topology acceptance gate","description":"Run the final quality gate after the code and regression corpus land. Confirm docs still keep raw topology experimental, confirm the public export boundary is intentional, and record closure evidence with exact commands and results. Artifacts: verification evidence on this case and any final doc or export touch-up needed to match the proven behavior. Proof: pnpm typecheck, pnpm build, pnpm test:analysis-golden, pnpm test:analysis-eval, and targeted pnpm test -- tests/analysis-structure.test.ts tests/cli.test.ts when the export or CLI boundary moved."}},"event_id":"01KPWNDAHSYXNFQA8J0R1G94SP","spec_version":"0.1-draft","revision_hint":20} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:56:00.235Z","type":"edge.added","source":"cli","command_id":"01KPWNDCVCNQ65CE77JJN23JBN","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"edge":{"edge_id":"edge_stabilize_after_quality_decision","type":"depends_on","source_id":"task_stabilize_topology_substrate","target_id":"decision_quality_first_structure","metadata":{},"extensions":{},"created_at":"2026-04-23T07:56:00.235Z"}},"event_id":"01KPWNDCVCJEHR03YG5ZRFWZ70","spec_version":"0.1-draft","revision_hint":21} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:56:03.761Z","type":"evidence.attached","source":"cli","command_id":"01KPWNDG9JH3M3RHB1HS9J0J2J","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"evidence_quality_first_cut","title":"Quality-first task cut for issue #9","description":"User preference captured: choose the structure with the fewest bugs and include the task that best guarantees quality. Adopted cut: 1) freeze invariants before code changes, 2) apply one minimal shared topology change, 3) lock regressions with edge-case golden and eval coverage, 4) run the full acceptance gate before closure. Rationale: keep implementation narrow, make proof obligations explicit, and prevent docs or fixture churn from hiding behavior changes inside the code task.","state":"done","labels":[],"acceptance":[],"metadata":{},"extensions":{},"kind":"evidence","created_at":"2026-04-23T07:56:03.761Z","updated_at":"2026-04-23T07:56:03.761Z"},"verifies_edge":{"edge_id":"01KPWNDG9JCJ8Q6MV8JR3AGX5S","type":"verifies","source_id":"evidence_quality_first_cut","target_id":"decision_quality_first_structure","metadata":{},"extensions":{},"created_at":"2026-04-23T07:56:03.761Z"}},"event_id":"01KPWNDG9JS6C0N59YW8ZG39HS","spec_version":"0.1-draft","revision_hint":22} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T07:59:58.905Z","type":"node.updated","source":"cli","command_id":"01KPWNMNXTVGNQ80XJX9PVJD0Y","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"goal_stabilize_topology_surface","changes":{"description":"Done means supported semantics for hard_unresolved and hard_goal_scope(goal_node_id) are explicit, normalization rules for self-loops and multi-edges are fixed, cycles/components/bridges/cutpoints/fragility share the same substrate, and docs plus verification keep raw topology experimental instead of promoting a stable cg analyze topology command. Documentation scope is deliberately narrow: only the contract and boundary statements needed to freeze semantics and to confirm the proven behavior. It is not a separate documentation expansion stream. Inputs: GitHub issue #9. Out of scope: stable raw topology CLI, temporal topology APIs, and broad doc rewrites unrelated to the promoted contract."}},"event_id":"01KPWNMNXT49FKWG8FQTVFCQ7R","spec_version":"0.1-draft","revision_hint":23} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:00:04.042Z","type":"node.updated","source":"cli","command_id":"01KPWNMTYB14P4BK9RCDJ9YS2J","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_define_topology_contract","changes":{"description":"Freeze the behavior before editing kernel code. Capture the promoted invariants for hard_unresolved and hard_goal_scope(goal_node_id), simple-undirected unresolved-hard normalization, self-loop and multi-edge treatment, empty goal-scope warnings, and the API boundary between experimental raw topology and user-facing analyses. Documentation work in this task is limited to the contract statements that must be true before implementation: ADR, README, and CLI spec wording needed to pin semantics and boundaries. Do not treat this as a general documentation expansion. Artifacts: docs/adr/0006-topology-projections-and-betti-v1.md, docs/README.md, docs/README.ja.md, docs/spec/05-cli.md, and checkpoint evidence. Proof: evidence with an invariant matrix, risky edge cases, and the exact kernel surfaces the implementation task may touch."}},"event_id":"01KPWNMTYBW07TBPHQHPSNA3G5","spec_version":"0.1-draft","revision_hint":24} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:00:08.232Z","type":"node.updated","source":"cli","command_id":"01KPWNMZ182AP8ZF0MSQFTJXCX","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_stabilize_topology_substrate","changes":{"description":"Apply the smallest shared kernel change that satisfies the frozen contract. Keep implementation in one slice centered on the shared topology helpers and only the consumers that must follow. Avoid mixing fixture expansion or broad doc rewrites into this task; documentation here is only incidental when code movement forces a boundary rename. Artifacts: packages/kernel/src/analysis-topology*.ts and only the directly dependent analysis modules or export surfaces that must change. Proof: targeted pnpm test -- tests/analysis-structure.test.ts tests/cli.test.ts, plus checkpoint evidence listing the touched files and remaining risk."}},"event_id":"01KPWNMZ18CKYZJ07JG19DJ4SG","spec_version":"0.1-draft","revision_hint":25} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:00:14.354Z","type":"node.updated","source":"cli","command_id":"01KPWNN50KEZE2TE08FCFPKDSZ","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_verify_topology_delivery","changes":{"description":"Run the final quality gate after the code and regression corpus land. Confirm the docs still match the proven behavior and still keep raw topology experimental, confirm the public export boundary is intentional, and record closure evidence with exact commands and results. Documentation work in this task is limited to final consistency fixes needed to align README, ADR, and CLI spec text with the verified behavior; it is not a separate documentation initiative. Artifacts: verification evidence on this case and any final doc or export touch-up needed to match the proven behavior. Proof: pnpm typecheck, pnpm build, pnpm test:analysis-golden, pnpm test:analysis-eval, and targeted pnpm test -- tests/analysis-structure.test.ts tests/cli.test.ts when the export or CLI boundary moved."}},"event_id":"01KPWNN50KSQMXCMD9DZZM1S4J","spec_version":"0.1-draft","revision_hint":26} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:00:18.955Z","type":"evidence.attached","source":"cli","command_id":"01KPWNN9GB1GNNHHYDZEBWW03G","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"evidence_docs_scope_for_issue_9","title":"Docs scope is contract-and-gate only","description":"Documentation for issue #9 is intentionally not a standalone workstream. The required doc work is limited to two places: 1) the contract-freeze task, where ADR/README/CLI spec wording pins projection semantics and the experimental-vs-user-facing boundary before code changes; 2) the final acceptance gate, where those same docs are checked for consistency with the verified behavior. Broad documentation expansion is out of scope for this case.","state":"done","labels":[],"acceptance":[],"metadata":{},"extensions":{},"kind":"evidence","created_at":"2026-04-23T08:00:18.955Z","updated_at":"2026-04-23T08:00:18.955Z"},"verifies_edge":{"edge_id":"01KPWNN9GC0XWPHHQN46VBD4KR","type":"verifies","source_id":"evidence_docs_scope_for_issue_9","target_id":"goal_stabilize_topology_surface","metadata":{},"extensions":{},"created_at":"2026-04-23T08:00:18.955Z"}},"event_id":"01KPWNN9GCAGMHRXRSP4W5YBQZ","spec_version":"0.1-draft","revision_hint":27} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:02:35.233Z","type":"node.state_changed","source":"cli","command_id":"01KPWNSEK2D0H8D3V70ABJYHPK","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_define_topology_contract","state":"doing"},"event_id":"01KPWNSEK2R95MT55MY1ZA35AC","spec_version":"0.1-draft","revision_hint":28} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:06:43.439Z","type":"evidence.attached","source":"cli","command_id":"01KPWP10ZG3WZ6Q5CWX1FNZ38F","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"evidence_define_topology_contract","title":"Freeze topology contract for issue #9","description":"Updated docs/adr/0006-topology-projections-and-betti-v1.md, docs/README.md, docs/README.ja.md, and docs/spec/05-cli.md to make the promoted substrate explicit: hard_unresolved uses unresolved nodes in todo/doing/waiting/failed plus hard edges; hard_goal_scope(goal_node_id) starts from unresolved contributors that reach the goal via contributes_to and closes over unresolved hard prerequisites; the projected graph is normalized to simple undirected form with multi-edge dedupe and self-loop warning self_loop_ignored; empty scoped graphs return warning scope_has_no_unresolved_nodes instead of failing; raw topology remains experimental only and stable CLI stays on cycles/components/bridges/cutpoints/fragility. Verified these statements against the current kernel substrate in packages/kernel/src/analysis-topology-shared.ts and existing topology/structure tests.","state":"done","labels":[],"acceptance":[],"metadata":{},"extensions":{},"kind":"evidence","created_at":"2026-04-23T08:06:43.439Z","updated_at":"2026-04-23T08:06:43.439Z"},"verifies_edge":{"edge_id":"01KPWP10ZGZ7XBA4S6ZN6YDZ57","type":"verifies","source_id":"evidence_define_topology_contract","target_id":"task_define_topology_contract","metadata":{},"extensions":{},"created_at":"2026-04-23T08:06:43.439Z"}},"event_id":"01KPWP10ZGGSYH9CSZRBXHA1A4","spec_version":"0.1-draft","revision_hint":29} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:06:45.771Z","type":"node.state_changed","source":"cli","command_id":"01KPWP138BA436QGDYE1T30A6H","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_define_topology_contract","state":"done"},"event_id":"01KPWP138C397P7JC9WAJG2N15","spec_version":"0.1-draft","revision_hint":30} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:07:04.072Z","type":"node.state_changed","source":"cli","command_id":"01KPWP1N48AH3Q61VRFXERYMJ4","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_stabilize_topology_substrate","state":"doing"},"event_id":"01KPWP1N48XJG4BWKGR8DT6TS6","spec_version":"0.1-draft","revision_hint":31} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:07:11.508Z","type":"evidence.attached","source":"cli","command_id":"01KPWP1WCR666S22YEGWJNVSAA","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"evidence_stabilize_topology_substrate","title":"Implementation slice already satisfies the frozen topology contract","description":"Reviewed the owned implementation slice with a subagent across packages/kernel/src/** and packages/core/src/experimental.ts. No code or package README changes were necessary. The shared substrate in packages/kernel/src/analysis-topology-shared.ts already defines hard_unresolved and hard_goal_scope(goal_node_id), uses reverse contributes_to reachability plus unresolved hard prerequisite closure for goal scope, normalizes to a simple undirected graph, deduplicates multi-edges, ignores self-loops with warning self_loop_ignored, and excludes invalid projection option combinations. Empty goal-scope warnings are already propagated in analysis-topology.ts, analysis-components.ts, analysis-bridges.ts, analysis-cutpoints.ts, and analysis-fragility.ts. Raw topology remains experimental via packages/core/src/experimental.ts and is absent from the root export and stable CLI. Verification already run in this session: pnpm test and pnpm test:analysis-golden passed.","state":"done","labels":[],"acceptance":[],"metadata":{},"extensions":{},"kind":"evidence","created_at":"2026-04-23T08:07:11.508Z","updated_at":"2026-04-23T08:07:11.508Z"},"verifies_edge":{"edge_id":"01KPWP1WCSFXCVC5GM4G1HAC33","type":"verifies","source_id":"evidence_stabilize_topology_substrate","target_id":"task_stabilize_topology_substrate","metadata":{},"extensions":{},"created_at":"2026-04-23T08:07:11.508Z"}},"event_id":"01KPWP1WCSYT5D0MEBSAC9DTYG","spec_version":"0.1-draft","revision_hint":32} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:07:14.135Z","type":"node.state_changed","source":"cli","command_id":"01KPWP1YYQ098CR40063Q89GNA","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_stabilize_topology_substrate","state":"done"},"event_id":"01KPWP1YYR6ZS6VHF1B35K6J53","spec_version":"0.1-draft","revision_hint":33} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:07:16.769Z","type":"node.state_changed","source":"cli","command_id":"01KPWP21H2YK76B8J7XBE5RW7G","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_expand_topology_corpus","state":"doing"},"event_id":"01KPWP21H2VHE3NYXBVP9BBZW6","spec_version":"0.1-draft","revision_hint":34} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:21:12.736Z","type":"evidence.attached","source":"cli","command_id":"01KPWPVHX1PXPNZMN43V424SCX","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"evidence_expand_topology_corpus","title":"Expanded topology golden and eval coverage for issue #9","description":"Added user-facing edge-case regression coverage in tests/analysis-structure.test.ts for empty goal scope warnings across cycles/components/bridges/cutpoints/fragility. Registered a new golden fixture in tests/helpers/analysis-golden.ts and added tests/fixtures/structure-analysis-edge-cases.fixture.json to prove figure-eight cycles, self-loop ignore-with-warning behavior, duplicate hard-edge normalization, and empty goal-scope warnings on structural surfaces. Extended tests/fixtures/analysis-eval-manifest.fixture.json with a new topology-edge-cases corpus backed by tests/fixtures/analysis-eval.topology-edge-cases.events.json so eval now proves disconnected forests, figure-eight cycles, goal-scoped projections, self-loop plus multi-edge normalization, and empty goal scopes through raw topology invariants and warning checks. Verification run in this session: pnpm exec vitest run tests/analysis-structure.test.ts tests/analysis-golden.test.ts tests/analysis-eval.test.ts passed.","state":"done","labels":[],"acceptance":[],"metadata":{},"extensions":{},"kind":"evidence","created_at":"2026-04-23T08:21:12.736Z","updated_at":"2026-04-23T08:21:12.736Z"},"verifies_edge":{"edge_id":"01KPWPVHX1R5NK6KBMPJKDXP6K","type":"verifies","source_id":"evidence_expand_topology_corpus","target_id":"task_expand_topology_corpus","metadata":{},"extensions":{},"created_at":"2026-04-23T08:21:12.736Z"}},"event_id":"01KPWPVHX1PM97X2JS1Y03YQ9F","spec_version":"0.1-draft","revision_hint":35} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:21:17.769Z","type":"node.state_changed","source":"cli","command_id":"01KPWPVPTD7G5THEW27R5JBV5B","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_expand_topology_corpus","state":"done"},"event_id":"01KPWPVPTDAG9VTGXEKH2S6480","spec_version":"0.1-draft","revision_hint":36} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:21:23.765Z","type":"node.state_changed","source":"cli","command_id":"01KPWPVWNPB5C9Y58C133XZVAN","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_verify_topology_delivery","state":"doing"},"event_id":"01KPWPVWNP13H7HDY22KAWCP00","spec_version":"0.1-draft","revision_hint":37} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:23:38.032Z","type":"evidence.attached","source":"cli","command_id":"01KPWPZZSHZK2Y9J4MMHCM7SHF","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node":{"node_id":"evidence_verify_topology_delivery","title":"Final acceptance gate for issue #9 passed","description":"Final verification was run by a subagent without further file edits. Commands and results: pnpm typecheck PASS; pnpm build PASS; pnpm test:analysis-golden PASS with analysis_golden_metrics.overall.hit_rate = 1; pnpm test:analysis-eval PASS with event_eval invariant.overall.hit_rate = 1 and partial_labels.overall.hit_rate = 1; pnpm exec vitest run tests/analysis-structure.test.ts tests/analysis-topology.test.ts PASS with 12/12 tests passing. Verified boundary alignment too: docs/README.md, docs/spec/05-cli.md, and docs/adr/0006-topology-projections-and-betti-v1.md all keep raw topology experimental and stable CLI on cycles/components/bridges/cutpoints/fragility, matching packages/core/src/experimental.ts, packages/core/src/index.ts, and packages/cli/src/app.ts.","state":"done","labels":[],"acceptance":[],"metadata":{},"extensions":{},"kind":"evidence","created_at":"2026-04-23T08:23:38.032Z","updated_at":"2026-04-23T08:23:38.032Z"},"verifies_edge":{"edge_id":"01KPWPZZSH11JFCHYF0XEE4WKN","type":"verifies","source_id":"evidence_verify_topology_delivery","target_id":"task_verify_topology_delivery","metadata":{},"extensions":{},"created_at":"2026-04-23T08:23:38.032Z"}},"event_id":"01KPWPZZSHRYGJMW9ETPMWPAGE","spec_version":"0.1-draft","revision_hint":38} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:23:42.697Z","type":"node.state_changed","source":"cli","command_id":"01KPWQ04BADY6627HMNTT04AGQ","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"task_verify_topology_delivery","state":"done"},"event_id":"01KPWQ04BA8S0AS3A77SRE8JJV","spec_version":"0.1-draft","revision_hint":39} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:23:47.180Z","type":"node.state_changed","source":"cli","command_id":"01KPWQ08QCCCQH8ND9N6M75N3H","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"node_id":"goal_stabilize_topology_surface","state":"done"},"event_id":"01KPWQ08QC75BYPH5A4SGK3AJR","spec_version":"0.1-draft","revision_hint":40} +{"case_id":"issue-9-topology-analysis-surface","timestamp":"2026-04-23T08:23:56.270Z","type":"case.updated","source":"cli","command_id":"01KPWQ0HKF40QZFFZSGA3HFJXN","actor":{"kind":"user","id":"local-user","display_name":"local-user"},"payload":{"changes":{"state":"closed"}},"event_id":"01KPWQ0HKG6J9ATBWY2HPKVJ7D","spec_version":"0.1-draft","revision_hint":41} diff --git a/docs/README.ja.md b/docs/README.ja.md index 1afac45..810e70b 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -190,6 +190,13 @@ CaseGraph が今持っている topology は、**依存グラフに対する内 これらは `depends_on` / `waits_for` / `contributes_to` を使う決定論的解析で、 現在の参照実装と golden corpus で継続検証されています。 +共有する substrate は次に限定して固定します。 + +- `hard_unresolved` は `todo` / `doing` / `waiting` / `failed` の unresolved node と、それらの間にある hard edge (`depends_on`, `waits_for`) だけを使う +- `hard_goal_scope(goal_node_id)` は `contributes_to` で goal に届く unresolved contributor から始め、そこから unresolved hard prerequisite closure を取る。goal node 自体や resolved node は projected graph に入れない +- scoping 後の graph は simple undirected に正規化し、direction は落とし、同じ endpoint pair の multi-edge は 1 本に潰し、self-loop は warning `self_loop_ignored` を返して無視する +- goal-scoped projection に unresolved node が 1 件もない場合は failure ではなく、empty result + warning `scope_has_no_unresolved_nodes` を返す + `topology` 自体は core / eval 用の experimental mechanism として残し、 root の `@caphtech/casegraph-core` ではなく `@caphtech/casegraph-core/experimental` からだけ参照できるようにし、 安定した user-facing command surface には直接出しません。 @@ -205,6 +212,7 @@ root の `@caphtech/casegraph-core` ではなく `@caphtech/casegraph-core/exper - undirected projection 上の `Betti-0` - undirected projection 上の `Betti-1` - component / cycle witness の説明 surface +- raw topology の projection 名は `hard_unresolved` と `hard_goal_scope(goal_node_id)` だけに固定する 非対象: @@ -214,7 +222,8 @@ root の `@caphtech/casegraph-core` ではなく `@caphtech/casegraph-core/exper - stable CLI / public schema の追加 参照実装では `packages/core` の raw topology API を `@caphtech/casegraph-core/experimental` に隔離し、 -`analysis-eval` harness で `beta_0` / `beta_1` / component set を継続検証しています。 +stable CLI は `cycles` / `components` / `bridges` / `cutpoints` / `fragility` に留め、 +`analysis-eval` harness で `beta_0` / `beta_1` / component set と scoped edge case の warning を継続検証しています。 詳細は [ADR-0006](docs/adr/0006-topology-projections-and-betti-v1.md) を参照。 diff --git a/docs/README.md b/docs/README.md index 1183a82..464ebe0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -190,6 +190,13 @@ The user-facing surface is not raw topology itself, but analysis surfaces that e These are deterministic analyses over `depends_on`, `waits_for`, and `contributes_to`, and they are continuously checked against the current reference implementation and golden corpus. +The shared substrate is fixed narrowly: + +- `hard_unresolved` means unresolved nodes in `todo` / `doing` / `waiting` / `failed` plus only hard edges (`depends_on`, `waits_for`) between them +- `hard_goal_scope(goal_node_id)` starts from unresolved contributors that reach the goal through `contributes_to`, then closes over unresolved hard prerequisites; the goal node itself and resolved nodes are not injected into the projected graph +- after scoping, the graph is normalized to a simple undirected form: direction is erased, duplicate endpoint pairs collapse to one edge, and self-loops are ignored with warning `self_loop_ignored` +- if a goal-scoped projection has no unresolved nodes, the result stays empty and returns warning `scope_has_no_unresolved_nodes` instead of failing + `topology` itself remains an experimental mechanism for core and evaluation use. It is only exposed through `@caphtech/casegraph-core/experimental`, not the root `@caphtech/casegraph-core`, and it is not surfaced directly as a stable user-facing command. @@ -205,6 +212,7 @@ At the moment, the experimental core surface fixes the following as the v1 targe - `Betti-0` over an undirected projection - `Betti-1` over an undirected projection - explanation surfaces for component and cycle witnesses +- `hard_unresolved` and `hard_goal_scope(goal_node_id)` as the only supported projection names for raw topology Out of scope: @@ -214,7 +222,8 @@ Out of scope: - new stable CLI or public schema additions In the reference implementation, the raw topology API in `packages/core` is isolated under `@caphtech/casegraph-core/experimental`, -and the `analysis-eval` harness continuously checks `beta_0`, `beta_1`, and component sets. +the stable CLI stays on `cycles`, `components`, `bridges`, `cutpoints`, and `fragility`, +and the `analysis-eval` harness continuously checks `beta_0`, `beta_1`, component sets, and warning behavior on scoped edge cases. See [ADR-0006](docs/adr/0006-topology-projections-and-betti-v1.md) for details. diff --git a/docs/adr/0006-topology-projections-and-betti-v1.md b/docs/adr/0006-topology-projections-and-betti-v1.md index a77bb35..0d502f6 100644 --- a/docs/adr/0006-topology-projections-and-betti-v1.md +++ b/docs/adr/0006-topology-projections-and-betti-v1.md @@ -31,8 +31,13 @@ v1 で扱う projection は次の 2 種類だけに固定する。 1. `hard_unresolved` - case 全体の unresolved hard graph + - unresolved node は `todo` / `doing` / `waiting` / `failed` に固定する + - graph edge は unresolved node 同士の `depends_on` / `waits_for` だけを使う 2. `hard_goal_scope(goal_node_id)` - - goal に `contributes_to` で到達する unresolved node と、その hard prerequisite closure + - goal に `contributes_to` で到達する unresolved node と、その unresolved hard prerequisite closure + - `contributes_to` は scope の切り出し専用で、normalized graph edge には入れない + - goal node 自体や resolved contributor は projection node に入れない + - scope が空でも failure にはせず、empty graph + warning で返す ### Node scope @@ -43,6 +48,9 @@ projection に含める unresolved state は、現行 critical-path/slack/bottle - `waiting` - `failed` +`done` / `cancelled` / `proposed` は、goal scope を切るときの `contributes_to` 上に存在しても +projection node には含めない。 + ### Edge scope projection に含める edge は hard dependency だけにする。 @@ -58,7 +66,8 @@ projection に含める edge は hard dependency だけにする。 - projection 後の計算対象は **simple undirected graph** とする - edge direction は Betti 計算には持ち込まない - multi-edge は endpoint が同じなら 1 本に正規化する -- self-loop は validation issue として扱い、topology result には warning を返す +- self-loop は normalized graph から除外し、warning `self_loop_ignored` を返す +- self-loop が validation で別途検出されることは妨げないが、topology 計算は loop を数えない ### Result shape (design-only) @@ -79,6 +88,11 @@ cycle_witnesses: warnings: [] ``` +warning contract は最小限として次を固定する。 + +- `scope_has_no_unresolved_nodes`: `hard_goal_scope(goal_node_id)` が unresolved node を 1 件も含まない +- `self_loop_ignored`: 入力に self-loop hard edge があり、normalized graph では無視した + ### Algebraic quantities - `beta_0`: connected component count @@ -87,6 +101,15 @@ warnings: [] `cycle_witnesses` は full basis を保証しない。 まずは representative cycle を返す説明用 surface とする。 +### Boundary to stable surfaces + +この ADR が固定するのは raw topology の projection semantics と warning semantics であり、 +stable CLI 名ではない。 + +- `cycles` / `components` / `bridges` / `cutpoints` / `fragility` は、この simple-undirected unresolved-hard substrate を共有する user-facing surface とする +- `cg analyze topology` や raw `beta_0` / `beta_1` / component witness は experimental core / eval surface に留める +- raw topology は `@caphtech/casegraph-core/experimental` からのみ参照可能とし、root export や stable CLI へ昇格しない + ## Consequences ### Positive diff --git a/docs/spec/05-cli.md b/docs/spec/05-cli.md index 1c6ac59..9fb2b4c 100644 --- a/docs/spec/05-cli.md +++ b/docs/spec/05-cli.md @@ -372,7 +372,15 @@ Phase 6 では graph 構造の説明 surface として次を追加していま - `cg analyze cutpoints --case [--goal ]` - `cg analyze fragility --case [--goal ]` -この surface は `packages/core` 内部の topology projection / component traversal を使うが、 +この structural surface は 1 つの normalized substrate を共有します。 + +- `--goal` なしは `hard_unresolved`: unresolved node (`todo` / `doing` / `waiting` / `failed`) と、その間の hard edge (`depends_on`, `waits_for`) を使う +- `--goal ` ありは `hard_goal_scope(goal_node_id)`: `contributes_to` で goal に届く unresolved contributor から始め、unresolved hard prerequisite closure を含める。goal node 自体や resolved node は含めない +- normalized graph は simple undirected とし、edge direction は落とし、同じ endpoint pair の multi-edge は 1 本に正規化し、self-loop は warning `self_loop_ignored` を返して無視する +- scope が空なら error にせず、空の result と warning `scope_has_no_unresolved_nodes` を返す + +この surface は `packages/kernel` の topology projection / component traversal を共通 substrate とし、 +`packages/core` はその上に raw topology の experimental surface を載せる wrapper として使うが、 `cg analyze topology` のような raw mechanism 名は stable CLI としては出さない。 raw topology (`beta_0`, `beta_1`, component witness) は `@caphtech/casegraph-core/experimental` へ隔離し、 user-facing CLI は `cycles`, `components`, `bridges`, `cutpoints`, `fragility` を保つ。 diff --git a/tests/analysis-structure.test.ts b/tests/analysis-structure.test.ts index 0b90ed1..97a25e5 100644 --- a/tests/analysis-structure.test.ts +++ b/tests/analysis-structure.test.ts @@ -183,6 +183,53 @@ describe("user-facing structure analyses", () => { } ]); }); + + it("warns consistently when goal scoping resolves to no unresolved nodes", () => { + const state = buildState({ + caseId: "empty-goal-structure-case", + nodes: [ + { node_id: "goal_archive_ready", kind: "goal", state: "done" }, + { node_id: "task_archived", state: "done" } + ], + edges: [ + { + edge_id: "e1", + type: "contributes_to", + source_id: "task_archived", + target_id: "goal_archive_ready" + } + ] + }); + + const options = { + projection: "hard_goal_scope" as const, + goalNodeId: "goal_archive_ready" + }; + + const cycles = analyzeCycles(state, options); + expect(cycles.cycle_count).toBe(0); + expect(cycles.cycles).toEqual([]); + expect(cycles.warnings).toEqual(["scope_has_no_unresolved_nodes"]); + + const components = analyzeComponents(state, options); + expect(components.component_count).toBe(0); + expect(components.components).toEqual([]); + expect(components.warnings).toEqual(["scope_has_no_unresolved_nodes"]); + + const bridges = analyzeBridges(state, options); + expect(bridges.bridge_count).toBe(0); + expect(bridges.bridges).toEqual([]); + expect(bridges.warnings).toEqual(["scope_has_no_unresolved_nodes"]); + + const cutpoints = analyzeCutpoints(state, options); + expect(cutpoints.cutpoint_count).toBe(0); + expect(cutpoints.cutpoints).toEqual([]); + expect(cutpoints.warnings).toEqual(["scope_has_no_unresolved_nodes"]); + + const fragility = analyzeFragility(state, options); + expect(fragility.nodes).toEqual([]); + expect(fragility.warnings).toEqual(["scope_has_no_unresolved_nodes"]); + }); }); function buildState(input: { caseId: string; nodes: TestNodeInput[]; edges: TestEdgeInput[] }) { diff --git a/tests/fixtures/analysis-eval-manifest.fixture.json b/tests/fixtures/analysis-eval-manifest.fixture.json index 957d187..209a09d 100644 --- a/tests/fixtures/analysis-eval-manifest.fixture.json +++ b/tests/fixtures/analysis-eval-manifest.fixture.json @@ -178,6 +178,79 @@ } } ] + }, + { + "corpus_id": "topology-edge-cases", + "events_file": "./analysis-eval.topology-edge-cases.events.json", + "queries": [ + { + "name": "hard unresolved topology keeps forest splits and figure-eight cycles", + "kind": "topology", + "projection": "hard_unresolved", + "labels": { + "expected_beta_0": 6, + "expected_beta_1": 2, + "expected_warning_ids": ["self_loop_ignored"], + "expected_component_node_sets": [ + ["goal_figure_ready"], + ["goal_noise_ready"], + ["task_fig_a", "task_fig_b", "task_fig_c", "task_fig_d", "task_fig_e"], + ["task_forest_a", "task_forest_b"], + ["task_forest_c", "task_forest_d"], + ["task_noise_a", "task_noise_b"] + ] + } + }, + { + "name": "raw cycles surface keeps both figure-eight witnesses", + "kind": "cycles", + "projection": "hard_unresolved", + "labels": { + "expected_cycle_node_sets": [ + ["task_fig_a", "task_fig_b", "task_fig_c"], + ["task_fig_c", "task_fig_d", "task_fig_e"] + ], + "expected_warning_ids": ["self_loop_ignored"] + } + }, + { + "name": "goal scoped topology keeps contributing figure-eight component", + "kind": "topology", + "projection": "hard_goal_scope", + "goal_node_id": "goal_figure_ready", + "labels": { + "expected_beta_0": 1, + "expected_beta_1": 2, + "expected_warning_ids": [], + "expected_component_node_sets": [ + ["task_fig_a", "task_fig_b", "task_fig_c", "task_fig_d", "task_fig_e"] + ] + } + }, + { + "name": "goal scoped topology normalizes noisy pair", + "kind": "topology", + "projection": "hard_goal_scope", + "goal_node_id": "goal_noise_ready", + "labels": { + "expected_beta_0": 1, + "expected_beta_1": 0, + "expected_warning_ids": ["self_loop_ignored"], + "expected_component_node_sets": [["task_noise_a", "task_noise_b"]] + } + }, + { + "name": "empty goal scope topology warns instead of failing", + "kind": "topology", + "projection": "hard_goal_scope", + "goal_node_id": "goal_archive_ready", + "labels": { + "expected_beta_0": 0, + "expected_beta_1": 0, + "expected_warning_ids": ["scope_has_no_unresolved_nodes"] + } + } + ] } ] } diff --git a/tests/fixtures/analysis-eval.topology-edge-cases.events.json b/tests/fixtures/analysis-eval.topology-edge-cases.events.json new file mode 100644 index 0000000..80f9859 --- /dev/null +++ b/tests/fixtures/analysis-eval.topology-edge-cases.events.json @@ -0,0 +1,664 @@ +[ + { + "event_id": "evt_topology_edges_0001", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:00.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "case.created", + "source": "cli", + "payload": { + "case": { + "case_id": "topology-edge-cases", + "title": "Topology edge cases", + "description": "Topology evaluation corpus for issue 9 edge cases", + "state": "open", + "labels": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0002", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:01.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "goal_figure_ready", + "kind": "goal", + "title": "Figure ready", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0003", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:02.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "goal_noise_ready", + "kind": "goal", + "title": "Noise ready", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0004", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:03.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "goal_archive_ready", + "kind": "goal", + "title": "Archive ready", + "description": "", + "state": "done", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0005", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:04.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_archived", + "kind": "task", + "title": "Archived", + "description": "", + "state": "done", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0006", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:05.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_forest_a", + "kind": "task", + "title": "Forest A", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0007", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:06.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_forest_b", + "kind": "task", + "title": "Forest B", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0008", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:07.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_forest_c", + "kind": "task", + "title": "Forest C", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0009", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:08.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_forest_d", + "kind": "task", + "title": "Forest D", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0010", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:09.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_fig_a", + "kind": "task", + "title": "Figure A", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0011", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:10.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_fig_b", + "kind": "task", + "title": "Figure B", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0012", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:11.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_fig_c", + "kind": "task", + "title": "Figure C", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0013", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:12.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_fig_d", + "kind": "task", + "title": "Figure D", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0014", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:13.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_fig_e", + "kind": "task", + "title": "Figure E", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0015", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:14.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_noise_a", + "kind": "task", + "title": "Noise A", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0016", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:15.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "node.added", + "source": "cli", + "payload": { + "node": { + "node_id": "task_noise_b", + "kind": "task", + "title": "Noise B", + "description": "", + "state": "todo", + "labels": [], + "acceptance": [], + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z", + "updated_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0017", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:16.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_forest_b_a", + "type": "depends_on", + "source_id": "task_forest_b", + "target_id": "task_forest_a", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0018", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:17.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_forest_d_c", + "type": "depends_on", + "source_id": "task_forest_d", + "target_id": "task_forest_c", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0019", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:18.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_fig_b_a", + "type": "depends_on", + "source_id": "task_fig_b", + "target_id": "task_fig_a", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0020", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:19.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_fig_c_b", + "type": "depends_on", + "source_id": "task_fig_c", + "target_id": "task_fig_b", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0021", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:20.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_fig_a_c", + "type": "depends_on", + "source_id": "task_fig_a", + "target_id": "task_fig_c", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0022", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:21.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_fig_d_c", + "type": "depends_on", + "source_id": "task_fig_d", + "target_id": "task_fig_c", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0023", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:22.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_fig_e_d", + "type": "depends_on", + "source_id": "task_fig_e", + "target_id": "task_fig_d", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0024", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:23.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_fig_c_e", + "type": "depends_on", + "source_id": "task_fig_c", + "target_id": "task_fig_e", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0025", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:24.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_noise_b_a", + "type": "depends_on", + "source_id": "task_noise_b", + "target_id": "task_noise_a", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0026", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:25.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_noise_a_b_duplicate", + "type": "depends_on", + "source_id": "task_noise_a", + "target_id": "task_noise_b", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0027", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:26.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_noise_a_a_self", + "type": "depends_on", + "source_id": "task_noise_a", + "target_id": "task_noise_a", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0028", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:27.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_fig_goal", + "type": "contributes_to", + "source_id": "task_fig_a", + "target_id": "goal_figure_ready", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0029", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:28.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_noise_goal", + "type": "contributes_to", + "source_id": "task_noise_b", + "target_id": "goal_noise_ready", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + }, + { + "event_id": "evt_topology_edges_0030", + "spec_version": "0.1-draft", + "case_id": "topology-edge-cases", + "timestamp": "2026-04-11T00:00:29.000Z", + "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, + "type": "edge.added", + "source": "cli", + "payload": { + "edge": { + "edge_id": "edge_archived_goal", + "type": "contributes_to", + "source_id": "task_archived", + "target_id": "goal_archive_ready", + "metadata": {}, + "extensions": {}, + "created_at": "2026-04-11T00:00:00.000Z" + } + } + } +] diff --git a/tests/fixtures/structure-analysis-edge-cases.fixture.json b/tests/fixtures/structure-analysis-edge-cases.fixture.json new file mode 100644 index 0000000..e049aea --- /dev/null +++ b/tests/fixtures/structure-analysis-edge-cases.fixture.json @@ -0,0 +1,201 @@ +{ + "seed_mode": "event_replay", + "case": { + "case_id": "structure-edge-cases", + "title": "Structure edge cases", + "description": "User-facing structure analysis edge cases" + }, + "nodes": [ + { + "node_id": "goal_archive_ready", + "kind": "goal", + "title": "Archive ready", + "state": "done" + }, + { + "node_id": "task_archived", + "kind": "task", + "title": "Archived", + "state": "done" + }, + { + "node_id": "task_a", + "kind": "task", + "title": "A", + "state": "todo" + }, + { + "node_id": "task_b", + "kind": "task", + "title": "B", + "state": "todo" + }, + { + "node_id": "task_c", + "kind": "task", + "title": "C", + "state": "todo" + }, + { + "node_id": "task_d", + "kind": "task", + "title": "D", + "state": "todo" + }, + { + "node_id": "task_e", + "kind": "task", + "title": "E", + "state": "todo" + } + ], + "edges": [ + { + "edge_id": "edge_archived_goal", + "type": "contributes_to", + "source_id": "task_archived", + "target_id": "goal_archive_ready" + }, + { + "edge_id": "edge_b_a", + "type": "depends_on", + "source_id": "task_b", + "target_id": "task_a" + }, + { + "edge_id": "edge_c_b", + "type": "depends_on", + "source_id": "task_c", + "target_id": "task_b" + }, + { + "edge_id": "edge_a_c", + "type": "depends_on", + "source_id": "task_a", + "target_id": "task_c" + }, + { + "edge_id": "edge_d_c", + "type": "depends_on", + "source_id": "task_d", + "target_id": "task_c" + }, + { + "edge_id": "edge_e_d", + "type": "depends_on", + "source_id": "task_e", + "target_id": "task_d" + }, + { + "edge_id": "edge_c_e", + "type": "depends_on", + "source_id": "task_c", + "target_id": "task_e" + }, + { + "edge_id": "edge_a_b_duplicate", + "type": "depends_on", + "source_id": "task_a", + "target_id": "task_b" + }, + { + "edge_id": "edge_a_a_self", + "type": "depends_on", + "source_id": "task_a", + "target_id": "task_a" + } + ], + "scenarios": [ + { + "name": "cycles on figure-eight graph ignore self-loop noise", + "cycles": { + "cycle_count": 2, + "cycle_node_sets": [ + ["task_a", "task_b", "task_c"], + ["task_c", "task_d", "task_e"] + ], + "warnings": ["self_loop_ignored"] + } + }, + { + "name": "components on noisy figure-eight graph stay normalized", + "components": { + "component_count": 1, + "component_node_sets": [["task_a", "task_b", "task_c", "task_d", "task_e"]], + "warnings": ["self_loop_ignored"] + } + }, + { + "name": "bridges on noisy figure-eight graph stay empty", + "bridges": { + "bridge_pairs": [], + "warnings": ["self_loop_ignored"] + } + }, + { + "name": "cutpoints on noisy figure-eight graph keep the shared center", + "cutpoints": { + "cutpoint_ids": ["task_c"], + "separated_component_node_sets_by_node": { + "task_c": [["task_a", "task_b"], ["task_d", "task_e"]] + }, + "warnings": ["self_loop_ignored"] + } + }, + { + "name": "fragility on noisy figure-eight graph ranks the articulation center", + "fragility": { + "node_ids": ["task_c"], + "top_node_id": "task_c", + "warnings": [ + "bottleneck_signal_unavailable_due_to_cycles", + "self_loop_ignored" + ] + } + }, + { + "name": "cycles warn on empty goal scope", + "cycles": { + "goal_node_id": "goal_archive_ready", + "cycle_count": 0, + "cycle_node_sets": [], + "warnings": ["scope_has_no_unresolved_nodes"] + } + }, + { + "name": "components warn on empty goal scope", + "components": { + "goal_node_id": "goal_archive_ready", + "component_count": 0, + "component_node_sets": [], + "warnings": ["scope_has_no_unresolved_nodes"] + } + }, + { + "name": "bridges warn on empty goal scope", + "bridges": { + "goal_node_id": "goal_archive_ready", + "bridge_pairs": [], + "warnings": ["scope_has_no_unresolved_nodes"] + } + }, + { + "name": "cutpoints warn on empty goal scope", + "cutpoints": { + "goal_node_id": "goal_archive_ready", + "cutpoint_ids": [], + "separated_component_node_sets_by_node": {}, + "warnings": ["scope_has_no_unresolved_nodes"] + } + }, + { + "name": "fragility warns on empty goal scope", + "fragility": { + "goal_node_id": "goal_archive_ready", + "node_ids": [], + "top_node_id": null, + "warnings": ["scope_has_no_unresolved_nodes"] + } + } + ] +} diff --git a/tests/helpers/analysis-golden.ts b/tests/helpers/analysis-golden.ts index 233cf18..2a818e0 100644 --- a/tests/helpers/analysis-golden.ts +++ b/tests/helpers/analysis-golden.ts @@ -23,6 +23,7 @@ import { import incidentAnalysisFixture from "../fixtures/incident-analysis.fixture.json"; import releaseAnalysisFixture from "../fixtures/release-analysis.fixture.json"; import structureAnalysisFixture from "../fixtures/structure-analysis.fixture.json"; +import structureAnalysisEdgeCasesFixture from "../fixtures/structure-analysis-edge-cases.fixture.json"; import topologyAnalysisFixture from "../fixtures/topology-analysis.fixture.json"; import vendorSelectionAnalysisFixture from "../fixtures/vendor-selection-analysis.fixture.json"; import { @@ -289,6 +290,7 @@ const fixtures: GoldenFixture[] = [ releaseAnalysisFixture as GoldenFixture, incidentAnalysisFixture as GoldenFixture, structureAnalysisFixture as GoldenFixture, + structureAnalysisEdgeCasesFixture as GoldenFixture, topologyAnalysisFixture as GoldenFixture, vendorSelectionAnalysisFixture as GoldenFixture ]; From 65a931f1eae477b516e75b5a058532816e43cf64 Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Thu, 23 Apr 2026 17:42:42 +0900 Subject: [PATCH 02/10] test: deduplicate topology replay fixtures --- .../analysis-eval-manifest.fixture.json | 18 +- ...lysis-eval.topology-edge-cases.events.json | 664 ------------------ ...structure-analysis-edge-cases.fixture.json | 124 +++- tests/helpers/analysis-eval.ts | 33 +- tests/helpers/analysis-golden.ts | 101 +-- tests/helpers/replay-fixture.ts | 99 +++ 6 files changed, 258 insertions(+), 781 deletions(-) delete mode 100644 tests/fixtures/analysis-eval.topology-edge-cases.events.json create mode 100644 tests/helpers/replay-fixture.ts diff --git a/tests/fixtures/analysis-eval-manifest.fixture.json b/tests/fixtures/analysis-eval-manifest.fixture.json index 209a09d..1c786dc 100644 --- a/tests/fixtures/analysis-eval-manifest.fixture.json +++ b/tests/fixtures/analysis-eval-manifest.fixture.json @@ -181,20 +181,18 @@ }, { "corpus_id": "topology-edge-cases", - "events_file": "./analysis-eval.topology-edge-cases.events.json", + "fixture_file": "./structure-analysis-edge-cases.fixture.json", "queries": [ { "name": "hard unresolved topology keeps forest splits and figure-eight cycles", "kind": "topology", "projection": "hard_unresolved", "labels": { - "expected_beta_0": 6, + "expected_beta_0": 4, "expected_beta_1": 2, "expected_warning_ids": ["self_loop_ignored"], "expected_component_node_sets": [ - ["goal_figure_ready"], - ["goal_noise_ready"], - ["task_fig_a", "task_fig_b", "task_fig_c", "task_fig_d", "task_fig_e"], + ["task_a", "task_b", "task_c", "task_d", "task_e"], ["task_forest_a", "task_forest_b"], ["task_forest_c", "task_forest_d"], ["task_noise_a", "task_noise_b"] @@ -207,8 +205,8 @@ "projection": "hard_unresolved", "labels": { "expected_cycle_node_sets": [ - ["task_fig_a", "task_fig_b", "task_fig_c"], - ["task_fig_c", "task_fig_d", "task_fig_e"] + ["task_a", "task_b", "task_c"], + ["task_c", "task_d", "task_e"] ], "expected_warning_ids": ["self_loop_ignored"] } @@ -221,9 +219,9 @@ "labels": { "expected_beta_0": 1, "expected_beta_1": 2, - "expected_warning_ids": [], + "expected_warning_ids": ["self_loop_ignored"], "expected_component_node_sets": [ - ["task_fig_a", "task_fig_b", "task_fig_c", "task_fig_d", "task_fig_e"] + ["task_a", "task_b", "task_c", "task_d", "task_e"] ] } }, @@ -235,7 +233,7 @@ "labels": { "expected_beta_0": 1, "expected_beta_1": 0, - "expected_warning_ids": ["self_loop_ignored"], + "expected_warning_ids": [], "expected_component_node_sets": [["task_noise_a", "task_noise_b"]] } }, diff --git a/tests/fixtures/analysis-eval.topology-edge-cases.events.json b/tests/fixtures/analysis-eval.topology-edge-cases.events.json deleted file mode 100644 index 80f9859..0000000 --- a/tests/fixtures/analysis-eval.topology-edge-cases.events.json +++ /dev/null @@ -1,664 +0,0 @@ -[ - { - "event_id": "evt_topology_edges_0001", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:00.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "case.created", - "source": "cli", - "payload": { - "case": { - "case_id": "topology-edge-cases", - "title": "Topology edge cases", - "description": "Topology evaluation corpus for issue 9 edge cases", - "state": "open", - "labels": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0002", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:01.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "goal_figure_ready", - "kind": "goal", - "title": "Figure ready", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0003", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:02.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "goal_noise_ready", - "kind": "goal", - "title": "Noise ready", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0004", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:03.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "goal_archive_ready", - "kind": "goal", - "title": "Archive ready", - "description": "", - "state": "done", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0005", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:04.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_archived", - "kind": "task", - "title": "Archived", - "description": "", - "state": "done", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0006", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:05.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_forest_a", - "kind": "task", - "title": "Forest A", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0007", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:06.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_forest_b", - "kind": "task", - "title": "Forest B", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0008", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:07.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_forest_c", - "kind": "task", - "title": "Forest C", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0009", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:08.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_forest_d", - "kind": "task", - "title": "Forest D", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0010", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:09.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_fig_a", - "kind": "task", - "title": "Figure A", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0011", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:10.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_fig_b", - "kind": "task", - "title": "Figure B", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0012", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:11.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_fig_c", - "kind": "task", - "title": "Figure C", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0013", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:12.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_fig_d", - "kind": "task", - "title": "Figure D", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0014", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:13.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_fig_e", - "kind": "task", - "title": "Figure E", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0015", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:14.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_noise_a", - "kind": "task", - "title": "Noise A", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0016", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:15.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "node.added", - "source": "cli", - "payload": { - "node": { - "node_id": "task_noise_b", - "kind": "task", - "title": "Noise B", - "description": "", - "state": "todo", - "labels": [], - "acceptance": [], - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z", - "updated_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0017", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:16.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_forest_b_a", - "type": "depends_on", - "source_id": "task_forest_b", - "target_id": "task_forest_a", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0018", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:17.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_forest_d_c", - "type": "depends_on", - "source_id": "task_forest_d", - "target_id": "task_forest_c", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0019", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:18.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_fig_b_a", - "type": "depends_on", - "source_id": "task_fig_b", - "target_id": "task_fig_a", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0020", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:19.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_fig_c_b", - "type": "depends_on", - "source_id": "task_fig_c", - "target_id": "task_fig_b", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0021", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:20.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_fig_a_c", - "type": "depends_on", - "source_id": "task_fig_a", - "target_id": "task_fig_c", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0022", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:21.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_fig_d_c", - "type": "depends_on", - "source_id": "task_fig_d", - "target_id": "task_fig_c", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0023", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:22.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_fig_e_d", - "type": "depends_on", - "source_id": "task_fig_e", - "target_id": "task_fig_d", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0024", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:23.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_fig_c_e", - "type": "depends_on", - "source_id": "task_fig_c", - "target_id": "task_fig_e", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0025", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:24.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_noise_b_a", - "type": "depends_on", - "source_id": "task_noise_b", - "target_id": "task_noise_a", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0026", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:25.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_noise_a_b_duplicate", - "type": "depends_on", - "source_id": "task_noise_a", - "target_id": "task_noise_b", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0027", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:26.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_noise_a_a_self", - "type": "depends_on", - "source_id": "task_noise_a", - "target_id": "task_noise_a", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0028", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:27.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_fig_goal", - "type": "contributes_to", - "source_id": "task_fig_a", - "target_id": "goal_figure_ready", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0029", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:28.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_noise_goal", - "type": "contributes_to", - "source_id": "task_noise_b", - "target_id": "goal_noise_ready", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - }, - { - "event_id": "evt_topology_edges_0030", - "spec_version": "0.1-draft", - "case_id": "topology-edge-cases", - "timestamp": "2026-04-11T00:00:29.000Z", - "actor": { "kind": "user", "id": "fixture", "display_name": "Fixture" }, - "type": "edge.added", - "source": "cli", - "payload": { - "edge": { - "edge_id": "edge_archived_goal", - "type": "contributes_to", - "source_id": "task_archived", - "target_id": "goal_archive_ready", - "metadata": {}, - "extensions": {}, - "created_at": "2026-04-11T00:00:00.000Z" - } - } - } -] diff --git a/tests/fixtures/structure-analysis-edge-cases.fixture.json b/tests/fixtures/structure-analysis-edge-cases.fixture.json index e049aea..331c573 100644 --- a/tests/fixtures/structure-analysis-edge-cases.fixture.json +++ b/tests/fixtures/structure-analysis-edge-cases.fixture.json @@ -12,6 +12,18 @@ "title": "Archive ready", "state": "done" }, + { + "node_id": "goal_figure_ready", + "kind": "goal", + "title": "Figure ready", + "state": "done" + }, + { + "node_id": "goal_noise_ready", + "kind": "goal", + "title": "Noise ready", + "state": "done" + }, { "node_id": "task_archived", "kind": "task", @@ -47,6 +59,42 @@ "kind": "task", "title": "E", "state": "todo" + }, + { + "node_id": "task_forest_a", + "kind": "task", + "title": "Forest A", + "state": "todo" + }, + { + "node_id": "task_forest_b", + "kind": "task", + "title": "Forest B", + "state": "todo" + }, + { + "node_id": "task_forest_c", + "kind": "task", + "title": "Forest C", + "state": "todo" + }, + { + "node_id": "task_forest_d", + "kind": "task", + "title": "Forest D", + "state": "todo" + }, + { + "node_id": "task_noise_a", + "kind": "task", + "title": "Noise A", + "state": "todo" + }, + { + "node_id": "task_noise_b", + "kind": "task", + "title": "Noise B", + "state": "todo" } ], "edges": [ @@ -103,6 +151,42 @@ "type": "depends_on", "source_id": "task_a", "target_id": "task_a" + }, + { + "edge_id": "edge_e_goal", + "type": "contributes_to", + "source_id": "task_e", + "target_id": "goal_figure_ready" + }, + { + "edge_id": "edge_forest_b_a", + "type": "depends_on", + "source_id": "task_forest_b", + "target_id": "task_forest_a" + }, + { + "edge_id": "edge_forest_d_c", + "type": "depends_on", + "source_id": "task_forest_d", + "target_id": "task_forest_c" + }, + { + "edge_id": "edge_noise_b_a", + "type": "depends_on", + "source_id": "task_noise_b", + "target_id": "task_noise_a" + }, + { + "edge_id": "edge_noise_a_b_duplicate", + "type": "depends_on", + "source_id": "task_noise_a", + "target_id": "task_noise_b" + }, + { + "edge_id": "edge_noise_goal", + "type": "contributes_to", + "source_id": "task_noise_b", + "target_id": "goal_noise_ready" } ], "scenarios": [ @@ -118,17 +202,26 @@ } }, { - "name": "components on noisy figure-eight graph stay normalized", + "name": "components on mixed edge-case graph expose four normalized regions", "components": { - "component_count": 1, - "component_node_sets": [["task_a", "task_b", "task_c", "task_d", "task_e"]], + "component_count": 4, + "component_node_sets": [ + ["task_a", "task_b", "task_c", "task_d", "task_e"], + ["task_forest_a", "task_forest_b"], + ["task_forest_c", "task_forest_d"], + ["task_noise_a", "task_noise_b"] + ], "warnings": ["self_loop_ignored"] } }, { - "name": "bridges on noisy figure-eight graph stay empty", + "name": "bridges on mixed edge-case graph keep only forest and noise cuts", "bridges": { - "bridge_pairs": [], + "bridge_pairs": [ + "task_forest_a::task_forest_b", + "task_forest_c::task_forest_d", + "task_noise_a::task_noise_b" + ], "warnings": ["self_loop_ignored"] } }, @@ -143,9 +236,17 @@ } }, { - "name": "fragility on noisy figure-eight graph ranks the articulation center", + "name": "fragility on mixed edge-case graph ranks the articulation center", "fragility": { - "node_ids": ["task_c"], + "node_ids": [ + "task_c", + "task_forest_a", + "task_forest_b", + "task_forest_c", + "task_forest_d", + "task_noise_a", + "task_noise_b" + ], "top_node_id": "task_c", "warnings": [ "bottleneck_signal_unavailable_due_to_cycles", @@ -153,6 +254,15 @@ ] } }, + { + "name": "goal-scoped components keep only the contributing figure-eight", + "components": { + "goal_node_id": "goal_figure_ready", + "component_count": 1, + "component_node_sets": [["task_a", "task_b", "task_c", "task_d", "task_e"]], + "warnings": ["self_loop_ignored"] + } + }, { "name": "cycles warn on empty goal scope", "cycles": { diff --git a/tests/helpers/analysis-eval.ts b/tests/helpers/analysis-eval.ts index 84515d3..a9de031 100644 --- a/tests/helpers/analysis-eval.ts +++ b/tests/helpers/analysis-eval.ts @@ -29,6 +29,7 @@ import { analyzeTopology, type TopologyAnalysisResult } from "@caphtech/casegraph-core/experimental"; +import { buildReplayStateFromFixture, type ReplayFixture } from "./replay-fixture.js"; export type EvalQueryKind = | "impact" @@ -75,7 +76,8 @@ export interface EvalQuerySpec { export interface EvalCorpusSpec { corpus_id: string; - events_file: string; + events_file?: string; + fixture_file?: string; queries: EvalQuerySpec[]; } @@ -144,8 +146,7 @@ export async function collectEventEvalMetrics(options: { const manifest = await loadEvalManifest(manifestPath); corpusCount += manifest.corpora.length; for (const corpus of manifest.corpora) { - const events = await loadEventsFile(manifestPath, corpus.events_file); - const state = replayCaseEvents(events); + const state = await loadEvalCorpusState(manifestPath, corpus); for (const query of corpus.queries) { queryMetrics.push( @@ -204,6 +205,32 @@ async function loadEventsFile( .map((line) => JSON.parse(line) as EventEnvelope); } +async function loadEvalCorpusState( + manifestPath: string, + corpus: EvalCorpusSpec +): Promise> { + if (corpus.events_file && corpus.fixture_file) { + throw new Error( + `Eval corpus ${corpus.corpus_id} must define either events_file or fixture_file, not both` + ); + } + + if (corpus.fixture_file) { + const resolvedPath = path.resolve(path.dirname(manifestPath), corpus.fixture_file); + const fixture = JSON.parse(await readFile(resolvedPath, "utf8")) as ReplayFixture; + return buildReplayStateFromFixture(fixture); + } + + if (corpus.events_file) { + const events = await loadEventsFile(manifestPath, corpus.events_file); + return replayCaseEvents(events); + } + + throw new Error( + `Eval corpus ${corpus.corpus_id} must define one of events_file or fixture_file` + ); +} + async function evaluateQueryMetric( manifestId: string, corpusId: string, diff --git a/tests/helpers/analysis-golden.ts b/tests/helpers/analysis-golden.ts index 2a818e0..aeb62c7 100644 --- a/tests/helpers/analysis-golden.ts +++ b/tests/helpers/analysis-golden.ts @@ -12,12 +12,7 @@ import { analyzeFragilityForCase, analyzeImpactForCase, analyzeMinimalUnblockSetForCase, - analyzeSlackForCase, - createEvent, - defaultActor, - type NodeKind, - type NodeState, - replayCaseEvents + analyzeSlackForCase } from "@caphtech/casegraph-core"; import incidentAnalysisFixture from "../fixtures/incident-analysis.fixture.json"; @@ -33,24 +28,9 @@ import { removeTempWorkspace, seedFixture } from "./workspace.js"; +import { buildReplayStateFromFixture, type ReplayFixture } from "./replay-fixture.js"; -export interface GoldenFixture { - seed_mode?: "workspace" | "event_replay"; - case: { case_id: string; title: string; description: string }; - nodes: Array<{ - node_id: string; - kind: NodeKind; - title: string; - state: NodeState; - metadata?: Record; - }>; - edges: Array<{ - edge_id: string; - type: "depends_on" | "waits_for" | "alternative_to" | "verifies" | "contributes_to"; - source_id: string; - target_id: string; - metadata?: Record; - }>; +export interface GoldenFixture extends ReplayFixture { scenarios: GoldenScenario[]; } @@ -340,7 +320,7 @@ export async function collectAnalysisGoldenMetrics(): Promise, + state: ReturnType, fixture: GoldenFixture, scenario: GoldenScenario ): ScenarioMetric { @@ -681,79 +661,6 @@ function metric( }; } -function buildReplayStateFromFixture(fixture: GoldenFixture) { - const baseTimestamp = new Date("2026-01-01T00:00:00.000Z").getTime(); - const actor = defaultActor(); - const caseId = fixture.case.case_id; - const events = [ - createEvent({ - case_id: caseId, - timestamp: new Date(baseTimestamp).toISOString(), - actor, - type: "case.created", - payload: { - case: { - case_id: caseId, - title: fixture.case.title, - description: fixture.case.description, - state: "open", - labels: [], - metadata: {}, - extensions: {}, - created_at: new Date(baseTimestamp).toISOString(), - updated_at: new Date(baseTimestamp).toISOString() - } - } - }), - ...fixture.nodes.map((node, index) => - createEvent({ - case_id: caseId, - timestamp: new Date(baseTimestamp + (index + 1) * 1_000).toISOString(), - actor, - type: "node.added", - payload: { - node: { - node_id: node.node_id, - kind: node.kind, - title: node.title, - description: "", - state: node.state, - labels: [], - acceptance: [], - metadata: node.metadata ?? {}, - extensions: {}, - created_at: new Date(baseTimestamp).toISOString(), - updated_at: new Date(baseTimestamp).toISOString() - } - } - }) - ), - ...fixture.edges.map((edge, index) => - createEvent({ - case_id: caseId, - timestamp: new Date( - baseTimestamp + (fixture.nodes.length + index + 1) * 1_000 - ).toISOString(), - actor, - type: "edge.added", - payload: { - edge: { - edge_id: edge.edge_id, - type: edge.type, - source_id: edge.source_id, - target_id: edge.target_id, - metadata: edge.metadata ?? {}, - extensions: {}, - created_at: new Date(baseTimestamp).toISOString() - } - } - }) - ) - ]; - - return replayCaseEvents(events); -} - function summarizeOverall(scenarios: ScenarioMetric[]): HitRate { const hits = scenarios.reduce( (total, scenario) => total + Object.values(scenario.checks).filter(Boolean).length, diff --git a/tests/helpers/replay-fixture.ts b/tests/helpers/replay-fixture.ts new file mode 100644 index 0000000..06fa5f8 --- /dev/null +++ b/tests/helpers/replay-fixture.ts @@ -0,0 +1,99 @@ +import { + createEvent, + defaultActor, + replayCaseEvents, + type NodeKind, + type NodeState +} from "@caphtech/casegraph-core"; + +export interface ReplayFixture { + seed_mode?: "workspace" | "event_replay"; + case: { case_id: string; title: string; description: string }; + nodes: Array<{ + node_id: string; + kind: NodeKind; + title: string; + state: NodeState; + metadata?: Record; + }>; + edges: Array<{ + edge_id: string; + type: "depends_on" | "waits_for" | "alternative_to" | "verifies" | "contributes_to"; + source_id: string; + target_id: string; + metadata?: Record; + }>; +} + +export function buildReplayStateFromFixture(fixture: ReplayFixture) { + const baseTimestamp = new Date("2026-01-01T00:00:00.000Z").getTime(); + const actor = defaultActor(); + const caseId = fixture.case.case_id; + const events = [ + createEvent({ + case_id: caseId, + timestamp: new Date(baseTimestamp).toISOString(), + actor, + type: "case.created", + payload: { + case: { + case_id: caseId, + title: fixture.case.title, + description: fixture.case.description, + state: "open", + labels: [], + metadata: {}, + extensions: {}, + created_at: new Date(baseTimestamp).toISOString(), + updated_at: new Date(baseTimestamp).toISOString() + } + } + }), + ...fixture.nodes.map((node, index) => + createEvent({ + case_id: caseId, + timestamp: new Date(baseTimestamp + (index + 1) * 1_000).toISOString(), + actor, + type: "node.added", + payload: { + node: { + node_id: node.node_id, + kind: node.kind, + title: node.title, + description: "", + state: node.state, + labels: [], + acceptance: [], + metadata: node.metadata ?? {}, + extensions: {}, + created_at: new Date(baseTimestamp).toISOString(), + updated_at: new Date(baseTimestamp).toISOString() + } + } + }) + ), + ...fixture.edges.map((edge, index) => + createEvent({ + case_id: caseId, + timestamp: new Date( + baseTimestamp + (fixture.nodes.length + index + 1) * 1_000 + ).toISOString(), + actor, + type: "edge.added", + payload: { + edge: { + edge_id: edge.edge_id, + type: edge.type, + source_id: edge.source_id, + target_id: edge.target_id, + metadata: edge.metadata ?? {}, + extensions: {}, + created_at: new Date(baseTimestamp).toISOString() + } + } + }) + ) + ]; + + return replayCaseEvents(events); +} From 01f9c70dcdcb5b4364decfb3e0e48da560aca2d9 Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Thu, 23 Apr 2026 17:55:14 +0900 Subject: [PATCH 03/10] test: add systematic topology analysis coverage --- tests/analysis-topology.test.ts | 60 ++ .../counterexamples/topology-analysis.yaml | 20 + tests/pbt/generators/topology.ts | 526 ++++++++++++++++++ tests/pbt/properties.yaml | 39 ++ tests/pbt/topology-properties.test.ts | 121 ++++ 5 files changed, 766 insertions(+) create mode 100644 tests/pbt/counterexamples/topology-analysis.yaml create mode 100644 tests/pbt/generators/topology.ts create mode 100644 tests/pbt/properties.yaml create mode 100644 tests/pbt/topology-properties.test.ts diff --git a/tests/analysis-topology.test.ts b/tests/analysis-topology.test.ts index af48ca2..90db042 100644 --- a/tests/analysis-topology.test.ts +++ b/tests/analysis-topology.test.ts @@ -243,6 +243,66 @@ describe("analyzeTopology", () => { ); }); + it("rejects a goal node id on hard unresolved projection", () => { + const state = buildState({ + caseId: "invalid-unresolved-goal-case", + nodes: [{ node_id: "goal_release_ready", kind: "goal" }, { node_id: "task_prepare" }], + edges: [] + }); + + expect(() => + analyzeTopology(state, { + projection: "hard_unresolved", + goalNodeId: "goal_release_ready" + }) + ).toThrowError( + expect.objectContaining({ + code: "analysis_goal_node_invalid_for_projection", + exitCode: 2 + }) + ); + }); + + it("requires the scoped node to be a goal", () => { + const state = buildState({ + caseId: "non-goal-scope-case", + nodes: [{ node_id: "task_prepare" }, { node_id: "task_review" }], + edges: [{ edge_id: "e1", source_id: "task_review", target_id: "task_prepare" }] + }); + + expect(() => + analyzeTopology(state, { + projection: "hard_goal_scope", + goalNodeId: "task_prepare" + }) + ).toThrowError( + expect.objectContaining({ + code: "node_not_goal", + exitCode: 2 + }) + ); + }); + + it("requires the scoped goal node to exist", () => { + const state = buildState({ + caseId: "unknown-goal-scope-case", + nodes: [{ node_id: "task_prepare" }], + edges: [] + }); + + expect(() => + analyzeTopology(state, { + projection: "hard_goal_scope", + goalNodeId: "goal_missing" + }) + ).toThrowError( + expect.objectContaining({ + code: "node_not_found", + exitCode: 3 + }) + ); + }); + it("loads topology analysis through the workspace wrapper", async () => { const workspaceRoot = await createTempWorkspace("casegraph-topology-"); createdWorkspaces.push(workspaceRoot); diff --git a/tests/pbt/counterexamples/topology-analysis.yaml b/tests/pbt/counterexamples/topology-analysis.yaml new file mode 100644 index 0000000..cce4850 --- /dev/null +++ b/tests/pbt/counterexamples/topology-analysis.yaml @@ -0,0 +1,20 @@ +module: topology-analysis +counterexamples: + - id: issue-9-self-loop-and-multi-edge + source: issue-9 topology analysis surface + fixture: tests/fixtures/structure-analysis-edge-cases.fixture.json + regression_test: tests/analysis-topology.test.ts::deduplicates multi-edges and ignores self-loops + expectation: + edge_count: 1 + warnings: + - self_loop_ignored + - id: issue-9-empty-goal-scope + source: issue-9 topology analysis surface + fixture: tests/fixtures/structure-analysis-edge-cases.fixture.json + regression_test: tests/analysis-topology.test.ts::warns when the scoped graph has no unresolved nodes + expectation: + node_count: 0 + beta_0: 0 + beta_1: 0 + warnings: + - scope_has_no_unresolved_nodes diff --git a/tests/pbt/generators/topology.ts b/tests/pbt/generators/topology.ts new file mode 100644 index 0000000..b97ba74 --- /dev/null +++ b/tests/pbt/generators/topology.ts @@ -0,0 +1,526 @@ +import { + createEvent, + defaultActor, + type NodeState, + replayCaseEvents +} from "@caphtech/casegraph-core"; +import fc from "fast-check"; + +type TopologyTaskState = Extract; + +export interface TopologyTaskBlueprint { + node_id: string; + state: TopologyTaskState; +} + +export interface TopologyHardEdgeBlueprint { + edge_id: string; + source_id: string; + target_id: string; +} + +export interface TopologyBlueprint { + caseId: string; + tasks: TopologyTaskBlueprint[]; + hardEdges: TopologyHardEdgeBlueprint[]; + goalNodeId?: string; + goalState?: TopologyTaskState; + contributorTaskIds?: string[]; +} + +export interface TopologyReferenceSummary { + node_count: number; + edge_count: number; + beta_0: number; + beta_1: number; + components: Array<{ node_ids: string[]; edge_count: number }>; + edge_keys: string[]; + node_ids: string[]; + warnings: string[]; +} + +const topologyTaskStateArb = fc.constantFrom( + "todo", + "doing", + "waiting", + "failed", + "done" +); + +export const topologyBlueprintArb: fc.Arbitrary = fc + .integer({ min: 2, max: 6 }) + .chain((count) => + fc + .uniqueArray(fc.integer({ min: 1, max: 999 }), { minLength: count, maxLength: count }) + .chain((values) => { + const taskIds = values.sort((left, right) => left - right).map((value) => `task_${value}`); + + return fc + .tuple( + fc.array(topologyTaskStateArb, { minLength: count, maxLength: count }), + arbitraryHardEdges(taskIds, { allowDuplicates: true, allowSelfLoops: true }) + ) + .map(([states, hardEdges]) => ({ + caseId: `topology-prop-${taskIds.join("-")}`, + tasks: taskIds.map((taskId, index) => ({ + node_id: taskId, + state: states[index] as TopologyTaskState + })), + hardEdges + })); + }) + ); + +export const simpleTopologyBlueprintArb: fc.Arbitrary = fc + .integer({ min: 2, max: 6 }) + .chain((count) => + fc + .uniqueArray(fc.integer({ min: 1, max: 999 }), { minLength: count, maxLength: count }) + .chain((values) => { + const taskIds = values.sort((left, right) => left - right).map((value) => `task_${value}`); + + return fc + .tuple( + fc.array(topologyTaskStateArb, { minLength: count, maxLength: count }), + arbitraryHardEdges(taskIds, { allowDuplicates: false, allowSelfLoops: false }) + ) + .map(([states, hardEdges]) => ({ + caseId: `topology-simple-prop-${taskIds.join("-")}`, + tasks: taskIds.map((taskId, index) => ({ + node_id: taskId, + state: states[index] as TopologyTaskState + })), + hardEdges + })); + }) + ); + +export const goalScopedTopologyBlueprintArb: fc.Arbitrary = + topologyBlueprintArb.chain((blueprint) => + fc + .tuple( + fc.subarray( + blueprint.tasks.map((task) => task.node_id), + { minLength: 1 } + ), + topologyTaskStateArb + ) + .map(([contributorTaskIds, goalState]) => ({ + ...blueprint, + goalNodeId: "goal_release_ready", + goalState, + contributorTaskIds: [...contributorTaskIds].sort((left, right) => left.localeCompare(right)) + })) + ); + +export function buildTopologyState(blueprint: TopologyBlueprint) { + const timestamp = "2026-01-01T00:00:00.000Z"; + const actor = defaultActor(); + + return replayCaseEvents([ + createEvent({ + case_id: blueprint.caseId, + timestamp, + actor, + type: "case.created", + payload: { + case: { + case_id: blueprint.caseId, + title: blueprint.caseId, + description: "", + state: "open", + labels: [], + metadata: {}, + extensions: {}, + created_at: timestamp, + updated_at: timestamp + } + } + }), + ...blueprint.tasks.map((task) => + createEvent({ + case_id: blueprint.caseId, + timestamp, + actor, + type: "node.added", + payload: { + node: { + node_id: task.node_id, + kind: "task", + title: task.node_id, + description: "", + state: task.state, + labels: [], + acceptance: [], + metadata: {}, + extensions: {}, + created_at: timestamp, + updated_at: timestamp + } + } + }) + ), + ...(blueprint.goalNodeId + ? [ + createEvent({ + case_id: blueprint.caseId, + timestamp, + actor, + type: "node.added" as const, + payload: { + node: { + node_id: blueprint.goalNodeId, + kind: "goal" as const, + title: blueprint.goalNodeId, + description: "", + state: blueprint.goalState ?? "done", + labels: [], + acceptance: [], + metadata: {}, + extensions: {}, + created_at: timestamp, + updated_at: timestamp + } + } + }) + ] + : []), + ...blueprint.hardEdges.map((edge) => + createEvent({ + case_id: blueprint.caseId, + timestamp, + actor, + type: "edge.added", + payload: { + edge: { + edge_id: edge.edge_id, + type: "depends_on", + source_id: edge.source_id, + target_id: edge.target_id, + metadata: {}, + extensions: {}, + created_at: timestamp + } + } + }) + ), + ...contributionEventsFromBlueprint(blueprint, timestamp, actor) + ]); +} + +export function buildReferenceTopology( + blueprint: TopologyBlueprint, + options: { projection?: "hard_unresolved" | "hard_goal_scope"; goalNodeId?: string } = {} +): TopologyReferenceSummary { + const projection = resolveProjection(blueprint, options); + const unresolvedNodeIds = collectUnresolvedNodeIds(blueprint.tasks); + const scopedNodeIds = collectScopedNodeIds(blueprint, projection, unresolvedNodeIds); + const { edgeKeys, warnings } = collectReferenceEdges(blueprint.hardEdges, scopedNodeIds); + const components = collectReferenceComponents(scopedNodeIds, edgeKeys); + + if (scopedNodeIds.length === 0) { + warnings.add("scope_has_no_unresolved_nodes"); + } + + return { + node_count: scopedNodeIds.length, + edge_count: edgeKeys.length, + beta_0: components.length, + beta_1: edgeKeys.length - scopedNodeIds.length + components.length, + components, + edge_keys: edgeKeys, + node_ids: scopedNodeIds, + warnings: [...warnings].sort(sortStrings) + }; +} + +export function addNormalizationNoise(blueprint: TopologyBlueprint): TopologyBlueprint { + const duplicateEdges = blueprint.hardEdges.slice(0, 3).map((edge, index) => ({ + edge_id: `dup_${index}_${edge.edge_id}`, + source_id: index % 2 === 0 ? edge.source_id : edge.target_id, + target_id: index % 2 === 0 ? edge.target_id : edge.source_id + })); + const selfLoops = blueprint.tasks.slice(0, 2).map((task, index) => ({ + edge_id: `self_${index}_${task.node_id}`, + source_id: task.node_id, + target_id: task.node_id + })); + + return { + ...blueprint, + caseId: `${blueprint.caseId}-noise`, + hardEdges: [...blueprint.hardEdges, ...duplicateEdges, ...selfLoops] + }; +} + +function arbitraryHardEdges( + taskIds: string[], + options: { allowDuplicates: boolean; allowSelfLoops: boolean } +): fc.Arbitrary { + return fc + .array( + fc.tuple( + fc.integer({ min: 0, max: taskIds.length - 1 }), + fc.integer({ min: 0, max: taskIds.length - 1 }), + fc.boolean() + ), + { minLength: 0, maxLength: taskIds.length * 3 } + ) + .map((entries) => materializeHardEdges(taskIds, entries, options)); +} + +function contributionEventsFromBlueprint( + blueprint: TopologyBlueprint, + timestamp: string, + actor: ReturnType +) { + if (!(blueprint.goalNodeId && blueprint.contributorTaskIds)) { + return []; + } + + return blueprint.contributorTaskIds.map((taskId, index) => + createEvent({ + case_id: blueprint.caseId, + timestamp, + actor, + type: "edge.added", + payload: { + edge: { + edge_id: `contrib_${index}`, + type: "contributes_to", + source_id: taskId, + target_id: blueprint.goalNodeId as string, + metadata: {}, + extensions: {}, + created_at: timestamp + } + } + }) + ); +} + +function resolveProjection( + blueprint: TopologyBlueprint, + options: { projection?: "hard_unresolved" | "hard_goal_scope"; goalNodeId?: string } +): "hard_unresolved" | "hard_goal_scope" { + const goalNodeId = options.goalNodeId ?? blueprint.goalNodeId; + return options.projection ?? (goalNodeId ? "hard_goal_scope" : "hard_unresolved"); +} + +function collectUnresolvedNodeIds(tasks: TopologyTaskBlueprint[]): Set { + return new Set(tasks.filter((task) => task.state !== "done").map((task) => task.node_id)); +} + +function collectScopedNodeIds( + blueprint: TopologyBlueprint, + projection: "hard_unresolved" | "hard_goal_scope", + unresolvedNodeIds: Set +): string[] { + if (projection === "hard_unresolved") { + return [...unresolvedNodeIds].sort(sortStrings); + } + + const scopedSeedIds = new Set(blueprint.contributorTaskIds ?? []); + expandWithUnresolvedPrerequisites(scopedSeedIds, blueprint.hardEdges, unresolvedNodeIds); + return [...scopedSeedIds].filter((nodeId) => unresolvedNodeIds.has(nodeId)).sort(sortStrings); +} + +function expandWithUnresolvedPrerequisites( + scopedSeedIds: Set, + hardEdges: TopologyHardEdgeBlueprint[], + unresolvedNodeIds: Set +): void { + const prerequisitesBySource = buildPrerequisiteAdjacency(hardEdges); + const stack = [...scopedSeedIds]; + + while (stack.length > 0) { + const currentNodeId = stack.pop() as string; + for (const prerequisiteNodeId of prerequisitesBySource.get(currentNodeId) ?? []) { + if (!unresolvedNodeIds.has(prerequisiteNodeId) || scopedSeedIds.has(prerequisiteNodeId)) { + continue; + } + scopedSeedIds.add(prerequisiteNodeId); + stack.push(prerequisiteNodeId); + } + } +} + +function buildPrerequisiteAdjacency(hardEdges: TopologyHardEdgeBlueprint[]): Map { + const prerequisitesBySource = new Map(); + + for (const edge of hardEdges) { + const prerequisites = prerequisitesBySource.get(edge.source_id) ?? []; + prerequisites.push(edge.target_id); + prerequisitesBySource.set(edge.source_id, prerequisites.sort(sortStrings)); + } + + return prerequisitesBySource; +} + +function collectReferenceEdges( + hardEdges: TopologyHardEdgeBlueprint[], + scopedNodeIds: string[] +): { edgeKeys: string[]; warnings: Set } { + const scopedNodeSet = new Set(scopedNodeIds); + const warnings = new Set(); + const edgeKeys = new Set(); + + for (const edge of hardEdges) { + if (!(scopedNodeSet.has(edge.source_id) && scopedNodeSet.has(edge.target_id))) { + continue; + } + if (edge.source_id === edge.target_id) { + warnings.add("self_loop_ignored"); + continue; + } + edgeKeys.add(edgeKey(edge.source_id, edge.target_id)); + } + + return { + edgeKeys: [...edgeKeys].sort(sortStrings), + warnings + }; +} + +function collectReferenceComponents( + scopedNodeIds: string[], + edgeKeys: string[] +): Array<{ node_ids: string[]; edge_count: number }> { + const adjacency = buildAdjacency(scopedNodeIds, edgeKeys); + const visited = new Set(); + const components: Array<{ node_ids: string[]; edge_count: number }> = []; + + for (const nodeId of scopedNodeIds) { + if (visited.has(nodeId)) { + continue; + } + + const componentNodeIds = collectConnectedNodeIds(nodeId, adjacency, visited); + components.push({ + node_ids: componentNodeIds, + edge_count: countEdgesWithinComponent(componentNodeIds, edgeKeys) + }); + } + + return components; +} + +function buildAdjacency(scopedNodeIds: string[], edgeKeys: string[]): Map { + const adjacency = new Map(); + + for (const nodeId of scopedNodeIds) { + adjacency.set(nodeId, []); + } + + for (const key of edgeKeys) { + const [sourceId, targetId] = key.split("::") as [string, string]; + adjacency.set(sourceId, [...(adjacency.get(sourceId) ?? []), targetId].sort(sortStrings)); + adjacency.set(targetId, [...(adjacency.get(targetId) ?? []), sourceId].sort(sortStrings)); + } + + return adjacency; +} + +function collectConnectedNodeIds( + startNodeId: string, + adjacency: Map, + visited: Set +): string[] { + const queue = [startNodeId]; + const componentNodeIds: string[] = []; + + while (queue.length > 0) { + const currentNodeId = queue.shift() as string; + if (visited.has(currentNodeId)) { + continue; + } + visited.add(currentNodeId); + componentNodeIds.push(currentNodeId); + + for (const neighborNodeId of adjacency.get(currentNodeId) ?? []) { + if (!visited.has(neighborNodeId)) { + queue.push(neighborNodeId); + } + } + } + + return componentNodeIds.sort(sortStrings); +} + +function countEdgesWithinComponent(componentNodeIds: string[], edgeKeys: string[]): number { + const componentNodeSet = new Set(componentNodeIds); + return edgeKeys.filter((key) => { + const [sourceId, targetId] = key.split("::") as [string, string]; + return componentNodeSet.has(sourceId) && componentNodeSet.has(targetId); + }).length; +} + +function materializeHardEdges( + taskIds: string[], + entries: Array<[number, number, boolean]>, + options: { allowDuplicates: boolean; allowSelfLoops: boolean } +): TopologyHardEdgeBlueprint[] { + const hardEdges: TopologyHardEdgeBlueprint[] = []; + const seenUndirectedPairs = new Set(); + + for (const [leftIndex, rightIndex, keepDirection] of entries) { + const leftNodeId = taskIds[leftIndex] as string; + const rightNodeId = taskIds[rightIndex] as string; + const undirectedKey = edgeKey(leftNodeId, rightNodeId); + + if ( + !shouldIncludeHardEdge(leftNodeId, rightNodeId, undirectedKey, seenUndirectedPairs, options) + ) { + continue; + } + + seenUndirectedPairs.add(undirectedKey); + hardEdges.push(buildHardEdge(hardEdges.length, leftNodeId, rightNodeId, keepDirection)); + } + + return hardEdges; +} + +function shouldIncludeHardEdge( + leftNodeId: string, + rightNodeId: string, + undirectedKey: string, + seenUndirectedPairs: Set, + options: { allowDuplicates: boolean; allowSelfLoops: boolean } +): boolean { + if (!options.allowSelfLoops && leftNodeId === rightNodeId) { + return false; + } + if (!options.allowDuplicates && seenUndirectedPairs.has(undirectedKey)) { + return false; + } + return true; +} + +function buildHardEdge( + index: number, + leftNodeId: string, + rightNodeId: string, + keepDirection: boolean +): TopologyHardEdgeBlueprint { + return { + edge_id: `hard_${index}`, + source_id: keepDirection ? leftNodeId : rightNodeId, + target_id: keepDirection ? rightNodeId : leftNodeId + }; +} + +function canonicalizeNodePair(leftNodeId: string, rightNodeId: string): [string, string] { + return leftNodeId.localeCompare(rightNodeId) <= 0 + ? [leftNodeId, rightNodeId] + : [rightNodeId, leftNodeId]; +} + +function edgeKey(leftNodeId: string, rightNodeId: string): string { + const [sourceId, targetId] = canonicalizeNodePair(leftNodeId, rightNodeId); + return `${sourceId}::${targetId}`; +} + +function sortStrings(left: string, right: string): number { + return left.localeCompare(right); +} diff --git a/tests/pbt/properties.yaml b/tests/pbt/properties.yaml new file mode 100644 index 0000000..dcb09ae --- /dev/null +++ b/tests/pbt/properties.yaml @@ -0,0 +1,39 @@ +module: topology-analysis +owners: + - packages/kernel/src/analysis-topology.ts + - packages/kernel/src/analysis-topology-shared.ts +unit_tests: + representative: + - file: tests/analysis-topology.test.ts + cases: + - extracts a single cycle witness from a simple loop + - extracts two witnesses from a figure-eight graph + - applies hard goal scoping to contributing unresolved nodes and prerequisites + boundary: + - file: tests/analysis-topology.test.ts + cases: + - deduplicates multi-edges and ignores self-loops + - warns when the scoped graph has no unresolved nodes + - requires a goal node for hard goal scope + - rejects a goal node id on hard unresolved projection + - requires the scoped node to be a goal + - requires the scoped goal node to exist +properties: + - id: PBT-topology-hard-unresolved-reference + kind: reference_model + level: L0-L2 + file: tests/pbt/topology-properties.test.ts + generator: tests/pbt/generators/topology.ts::topologyBlueprintArb + law: hard_unresolved projection matches the simple undirected normalization model + - id: PBT-topology-hard-goal-scope-reference + kind: reference_model + level: L0-L2 + file: tests/pbt/topology-properties.test.ts + generator: tests/pbt/generators/topology.ts::goalScopedTopologyBlueprintArb + law: hard_goal_scope projection equals contributor closure plus unresolved prerequisites + - id: PBT-topology-normalization-metamorphic + kind: metamorphic + level: L1-L2 + file: tests/pbt/topology-properties.test.ts + generator: tests/pbt/generators/topology.ts::simpleTopologyBlueprintArb + law: duplicate hard edges and self-loops do not change the normalized topology surface diff --git a/tests/pbt/topology-properties.test.ts b/tests/pbt/topology-properties.test.ts new file mode 100644 index 0000000..1091880 --- /dev/null +++ b/tests/pbt/topology-properties.test.ts @@ -0,0 +1,121 @@ +import { analyzeTopology } from "@caphtech/casegraph-core/experimental"; +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { + addNormalizationNoise, + buildReferenceTopology, + buildTopologyState, + goalScopedTopologyBlueprintArb, + simpleTopologyBlueprintArb, + type TopologyReferenceSummary, + topologyBlueprintArb +} from "./generators/topology.js"; + +describe("property: topology analysis invariants", () => { + it("P1 hard_unresolved matches the reference normalization model", () => { + fc.assert( + fc.property(topologyBlueprintArb, (blueprint) => { + const result = analyzeTopology(buildTopologyState(blueprint)); + const expected = buildReferenceTopology(blueprint); + + expect(summarizeResult(result)).toEqual(summarizeReference(expected)); + expectCycleWitnessesToReferenceEdges(result.cycle_witnesses, expected); + expect(result.cycle_witnesses.length).toBeLessThanOrEqual(result.beta_1); + }), + { numRuns: 60 } + ); + }); + + it("P2 hard_goal_scope matches contributor and prerequisite closure", () => { + fc.assert( + fc.property(goalScopedTopologyBlueprintArb, (blueprint) => { + const goalNodeId = blueprint.goalNodeId as string; + const result = analyzeTopology(buildTopologyState(blueprint), { + projection: "hard_goal_scope", + goalNodeId + }); + const expected = buildReferenceTopology(blueprint, { + projection: "hard_goal_scope", + goalNodeId + }); + + expect(summarizeResult(result)).toEqual(summarizeReference(expected)); + expect(result.goal_node_id).toBe(goalNodeId); + expect(result.components.flatMap((component) => component.node_ids)).not.toContain( + goalNodeId + ); + expectCycleWitnessesToReferenceEdges(result.cycle_witnesses, expected); + }), + { numRuns: 60 } + ); + }); + + it("P3 duplicate hard edges and self-loops do not change normalized topology", () => { + fc.assert( + fc.property(simpleTopologyBlueprintArb, (blueprint) => { + const baseResult = analyzeTopology(buildTopologyState(blueprint)); + const noisyBlueprint = addNormalizationNoise(blueprint); + const noisyResult = analyzeTopology(buildTopologyState(noisyBlueprint)); + const noisyReference = buildReferenceTopology(noisyBlueprint); + + expect(summarizeResultWithoutWarnings(noisyResult)).toEqual( + summarizeResultWithoutWarnings(baseResult) + ); + expect(noisyResult.cycle_witnesses).toEqual(baseResult.cycle_witnesses); + expect(noisyResult.warnings).toEqual(noisyReference.warnings); + }), + { numRuns: 40 } + ); + }); +}); + +function summarizeResult(result: ReturnType) { + return { + node_count: result.node_count, + edge_count: result.edge_count, + beta_0: result.beta_0, + beta_1: result.beta_1, + components: result.components, + warnings: result.warnings + }; +} + +function summarizeReference(reference: TopologyReferenceSummary) { + return { + node_count: reference.node_count, + edge_count: reference.edge_count, + beta_0: reference.beta_0, + beta_1: reference.beta_1, + components: reference.components, + warnings: reference.warnings + }; +} + +function summarizeResultWithoutWarnings(result: ReturnType) { + return { + node_count: result.node_count, + edge_count: result.edge_count, + beta_0: result.beta_0, + beta_1: result.beta_1, + components: result.components + }; +} + +function expectCycleWitnessesToReferenceEdges( + cycleWitnesses: ReturnType["cycle_witnesses"], + reference: TopologyReferenceSummary +): void { + const referenceNodeIds = new Set(reference.node_ids); + const referenceEdgeKeys = new Set(reference.edge_keys); + + for (const witness of cycleWitnesses) { + for (const nodeId of witness.node_ids) { + expect(referenceNodeIds.has(nodeId)).toBe(true); + } + for (const edgePair of witness.edge_pairs) { + const edgeKey = [edgePair.source_id, edgePair.target_id].sort().join("::"); + expect(referenceEdgeKeys.has(edgeKey)).toBe(true); + } + } +} From fd71606f7ff1eaa19defcf8a3e070af9dd93812a Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Thu, 23 Apr 2026 21:02:45 +0900 Subject: [PATCH 04/10] fix: align topology goal scope semantics --- .../kernel/src/analysis-topology-shared.ts | 5 +- tests/analysis-topology.test.ts | 75 +++++++++++++++++++ .../analysis-eval-manifest.fixture.json | 15 +++- ...structure-analysis-edge-cases.fixture.json | 45 ++++++++--- tests/helpers/analysis-eval.ts | 4 +- tests/helpers/analysis-golden.ts | 2 +- tests/helpers/replay-fixture.ts | 4 +- .../counterexamples/topology-analysis.yaml | 9 +++ tests/pbt/generators/topology.ts | 61 +++++++++++++-- tests/pbt/properties.yaml | 10 ++- tests/pbt/topology-properties.test.ts | 18 +++++ 11 files changed, 219 insertions(+), 29 deletions(-) diff --git a/packages/kernel/src/analysis-topology-shared.ts b/packages/kernel/src/analysis-topology-shared.ts index 5284cd1..a448708 100644 --- a/packages/kernel/src/analysis-topology-shared.ts +++ b/packages/kernel/src/analysis-topology-shared.ts @@ -242,8 +242,9 @@ function collectGoalScopedNodeIds( "source_id" ) ); + const unresolvedScoped = new Set([...scoped].filter((nodeId) => unresolvedById.has(nodeId))); expandScopeWithHardPrerequisites( - scoped, + unresolvedScoped, buildStringAdjacency( state.edges, (edge) => HARD_DEPENDENCY_TYPES.has(edge.type), @@ -253,7 +254,7 @@ function collectGoalScopedNodeIds( unresolvedById ); - return [...scoped].sort((left, right) => left.localeCompare(right)); + return [...unresolvedScoped].sort((left, right) => left.localeCompare(right)); } function buildStringAdjacency( diff --git a/tests/analysis-topology.test.ts b/tests/analysis-topology.test.ts index 90db042..1e5cd5a 100644 --- a/tests/analysis-topology.test.ts +++ b/tests/analysis-topology.test.ts @@ -197,6 +197,81 @@ describe("analyzeTopology", () => { expect(result.beta_1).toBe(1); }); + it("treats waits_for edges as hard prerequisites in goal scope", () => { + const state = buildState({ + caseId: "goal-scope-waits-for-case", + nodes: [ + { node_id: "goal_release_ready", kind: "goal" }, + { node_id: "task_publish" }, + { node_id: "task_review", state: "waiting" } + ], + edges: [ + { + edge_id: "e1", + type: "waits_for", + source_id: "task_publish", + target_id: "task_review" + }, + { + edge_id: "e2", + type: "contributes_to", + source_id: "task_publish", + target_id: "goal_release_ready" + } + ] + }); + + const result = analyzeTopology(state, { + projection: "hard_goal_scope", + goalNodeId: "goal_release_ready" + }); + + expect(result.components).toEqual([ + { + node_ids: ["task_publish", "task_review"], + edge_count: 1 + } + ]); + expect(result.edge_count).toBe(1); + expect(result.beta_0).toBe(1); + expect(result.beta_1).toBe(0); + }); + + it("does not seed goal scope from resolved contributors", () => { + const state = buildState({ + caseId: "goal-scope-resolved-contributor-case", + nodes: [ + { node_id: "goal_release_ready", kind: "goal", state: "done" }, + { node_id: "task_publish", state: "done" }, + { node_id: "task_review", state: "waiting" } + ], + edges: [ + { + edge_id: "e1", + type: "waits_for", + source_id: "task_publish", + target_id: "task_review" + }, + { + edge_id: "e2", + type: "contributes_to", + source_id: "task_publish", + target_id: "goal_release_ready" + } + ] + }); + + const result = analyzeTopology(state, { + projection: "hard_goal_scope", + goalNodeId: "goal_release_ready" + }); + + expect(result.node_count).toBe(0); + expect(result.edge_count).toBe(0); + expect(result.components).toEqual([]); + expect(result.warnings).toEqual(["scope_has_no_unresolved_nodes"]); + }); + it("warns when the scoped graph has no unresolved nodes", () => { const state = buildState({ caseId: "empty-scope-case", diff --git a/tests/fixtures/analysis-eval-manifest.fixture.json b/tests/fixtures/analysis-eval-manifest.fixture.json index 1c786dc..d477f7e 100644 --- a/tests/fixtures/analysis-eval-manifest.fixture.json +++ b/tests/fixtures/analysis-eval-manifest.fixture.json @@ -220,9 +220,7 @@ "expected_beta_0": 1, "expected_beta_1": 2, "expected_warning_ids": ["self_loop_ignored"], - "expected_component_node_sets": [ - ["task_a", "task_b", "task_c", "task_d", "task_e"] - ] + "expected_component_node_sets": [["task_a", "task_b", "task_c", "task_d", "task_e"]] } }, { @@ -237,6 +235,17 @@ "expected_component_node_sets": [["task_noise_a", "task_noise_b"]] } }, + { + "name": "goal scoped topology ignores unresolved prerequisites behind resolved contributors", + "kind": "topology", + "projection": "hard_goal_scope", + "goal_node_id": "goal_resolved_ready", + "labels": { + "expected_beta_0": 0, + "expected_beta_1": 0, + "expected_warning_ids": ["scope_has_no_unresolved_nodes"] + } + }, { "name": "empty goal scope topology warns instead of failing", "kind": "topology", diff --git a/tests/fixtures/structure-analysis-edge-cases.fixture.json b/tests/fixtures/structure-analysis-edge-cases.fixture.json index 331c573..3c98827 100644 --- a/tests/fixtures/structure-analysis-edge-cases.fixture.json +++ b/tests/fixtures/structure-analysis-edge-cases.fixture.json @@ -24,6 +24,12 @@ "title": "Noise ready", "state": "done" }, + { + "node_id": "goal_resolved_ready", + "kind": "goal", + "title": "Resolved ready", + "state": "done" + }, { "node_id": "task_archived", "kind": "task", @@ -95,6 +101,12 @@ "kind": "task", "title": "Noise B", "state": "todo" + }, + { + "node_id": "task_resolved_bridge", + "kind": "task", + "title": "Resolved Bridge", + "state": "done" } ], "edges": [ @@ -172,7 +184,7 @@ }, { "edge_id": "edge_noise_b_a", - "type": "depends_on", + "type": "waits_for", "source_id": "task_noise_b", "target_id": "task_noise_a" }, @@ -187,6 +199,18 @@ "type": "contributes_to", "source_id": "task_noise_b", "target_id": "goal_noise_ready" + }, + { + "edge_id": "edge_resolved_bridge_wait", + "type": "waits_for", + "source_id": "task_resolved_bridge", + "target_id": "task_noise_a" + }, + { + "edge_id": "edge_resolved_bridge_goal", + "type": "contributes_to", + "source_id": "task_resolved_bridge", + "target_id": "goal_resolved_ready" } ], "scenarios": [ @@ -194,10 +218,7 @@ "name": "cycles on figure-eight graph ignore self-loop noise", "cycles": { "cycle_count": 2, - "cycle_node_sets": [ - ["task_a", "task_b", "task_c"], - ["task_c", "task_d", "task_e"] - ], + "cycle_node_sets": [["task_a", "task_b", "task_c"], ["task_c", "task_d", "task_e"]], "warnings": ["self_loop_ignored"] } }, @@ -248,10 +269,7 @@ "task_noise_b" ], "top_node_id": "task_c", - "warnings": [ - "bottleneck_signal_unavailable_due_to_cycles", - "self_loop_ignored" - ] + "warnings": ["bottleneck_signal_unavailable_due_to_cycles", "self_loop_ignored"] } }, { @@ -263,6 +281,15 @@ "warnings": ["self_loop_ignored"] } }, + { + "name": "resolved contributors do not seed goal-scoped prerequisites", + "components": { + "goal_node_id": "goal_resolved_ready", + "component_count": 0, + "component_node_sets": [], + "warnings": ["scope_has_no_unresolved_nodes"] + } + }, { "name": "cycles warn on empty goal scope", "cycles": { diff --git a/tests/helpers/analysis-eval.ts b/tests/helpers/analysis-eval.ts index a9de031..cd912b3 100644 --- a/tests/helpers/analysis-eval.ts +++ b/tests/helpers/analysis-eval.ts @@ -226,9 +226,7 @@ async function loadEvalCorpusState( return replayCaseEvents(events); } - throw new Error( - `Eval corpus ${corpus.corpus_id} must define one of events_file or fixture_file` - ); + throw new Error(`Eval corpus ${corpus.corpus_id} must define one of events_file or fixture_file`); } async function evaluateQueryMetric( diff --git a/tests/helpers/analysis-golden.ts b/tests/helpers/analysis-golden.ts index aeb62c7..3b932d0 100644 --- a/tests/helpers/analysis-golden.ts +++ b/tests/helpers/analysis-golden.ts @@ -21,6 +21,7 @@ import structureAnalysisFixture from "../fixtures/structure-analysis.fixture.jso import structureAnalysisEdgeCasesFixture from "../fixtures/structure-analysis-edge-cases.fixture.json"; import topologyAnalysisFixture from "../fixtures/topology-analysis.fixture.json"; import vendorSelectionAnalysisFixture from "../fixtures/vendor-selection-analysis.fixture.json"; +import { buildReplayStateFromFixture, type ReplayFixture } from "./replay-fixture.js"; import { applyFixtureActions, createTempWorkspace, @@ -28,7 +29,6 @@ import { removeTempWorkspace, seedFixture } from "./workspace.js"; -import { buildReplayStateFromFixture, type ReplayFixture } from "./replay-fixture.js"; export interface GoldenFixture extends ReplayFixture { scenarios: GoldenScenario[]; diff --git a/tests/helpers/replay-fixture.ts b/tests/helpers/replay-fixture.ts index 06fa5f8..3ac6848 100644 --- a/tests/helpers/replay-fixture.ts +++ b/tests/helpers/replay-fixture.ts @@ -1,9 +1,9 @@ import { createEvent, defaultActor, - replayCaseEvents, type NodeKind, - type NodeState + type NodeState, + replayCaseEvents } from "@caphtech/casegraph-core"; export interface ReplayFixture { diff --git a/tests/pbt/counterexamples/topology-analysis.yaml b/tests/pbt/counterexamples/topology-analysis.yaml index cce4850..2f60ec5 100644 --- a/tests/pbt/counterexamples/topology-analysis.yaml +++ b/tests/pbt/counterexamples/topology-analysis.yaml @@ -18,3 +18,12 @@ counterexamples: beta_1: 0 warnings: - scope_has_no_unresolved_nodes + - id: issue-9-resolved-contributor-exclusion + source: critical-code-review follow-up + fixture: tests/fixtures/structure-analysis-edge-cases.fixture.json + regression_test: tests/analysis-topology.test.ts::does not seed goal scope from resolved contributors + expectation: + node_count: 0 + edge_count: 0 + warnings: + - scope_has_no_unresolved_nodes diff --git a/tests/pbt/generators/topology.ts b/tests/pbt/generators/topology.ts index b97ba74..5bcf580 100644 --- a/tests/pbt/generators/topology.ts +++ b/tests/pbt/generators/topology.ts @@ -1,6 +1,7 @@ import { createEvent, defaultActor, + type EdgeType, type NodeState, replayCaseEvents } from "@caphtech/casegraph-core"; @@ -15,6 +16,7 @@ export interface TopologyTaskBlueprint { export interface TopologyHardEdgeBlueprint { edge_id: string; + type: Extract; source_id: string; target_id: string; } @@ -46,6 +48,10 @@ const topologyTaskStateArb = fc.constantFrom( "failed", "done" ); +const topologyHardEdgeTypeArb = fc.constantFrom>( + "depends_on", + "waits_for" +); export const topologyBlueprintArb: fc.Arbitrary = fc .integer({ min: 2, max: 6 }) @@ -113,6 +119,38 @@ export const goalScopedTopologyBlueprintArb: fc.Arbitrary = })) ); +export const resolvedContributorScopeBlueprintArb: fc.Arbitrary = + topologyBlueprintArb + .filter((blueprint) => blueprint.tasks.some((task) => task.state !== "done")) + .map((blueprint) => { + const prerequisiteTaskId = + blueprint.tasks.find((task) => task.state !== "done")?.node_id ?? "task_fallback"; + + return { + ...blueprint, + caseId: `${blueprint.caseId}-resolved-contributor`, + tasks: [ + ...blueprint.tasks, + { + node_id: "task_resolved_bridge", + state: "done" + } + ], + goalNodeId: "goal_release_ready", + goalState: "done", + contributorTaskIds: ["task_resolved_bridge"], + hardEdges: [ + ...blueprint.hardEdges, + { + edge_id: "resolved_bridge_wait", + type: "waits_for", + source_id: "task_resolved_bridge", + target_id: prerequisiteTaskId + } + ] + }; + }); + export function buildTopologyState(blueprint: TopologyBlueprint) { const timestamp = "2026-01-01T00:00:00.000Z"; const actor = defaultActor(); @@ -194,7 +232,7 @@ export function buildTopologyState(blueprint: TopologyBlueprint) { payload: { edge: { edge_id: edge.edge_id, - type: "depends_on", + type: edge.type, source_id: edge.source_id, target_id: edge.target_id, metadata: {}, @@ -237,11 +275,13 @@ export function buildReferenceTopology( export function addNormalizationNoise(blueprint: TopologyBlueprint): TopologyBlueprint { const duplicateEdges = blueprint.hardEdges.slice(0, 3).map((edge, index) => ({ edge_id: `dup_${index}_${edge.edge_id}`, + type: edge.type, source_id: index % 2 === 0 ? edge.source_id : edge.target_id, target_id: index % 2 === 0 ? edge.target_id : edge.source_id })); const selfLoops = blueprint.tasks.slice(0, 2).map((task, index) => ({ edge_id: `self_${index}_${task.node_id}`, + type: index % 2 === 0 ? "depends_on" : "waits_for", source_id: task.node_id, target_id: task.node_id })); @@ -262,7 +302,8 @@ function arbitraryHardEdges( fc.tuple( fc.integer({ min: 0, max: taskIds.length - 1 }), fc.integer({ min: 0, max: taskIds.length - 1 }), - fc.boolean() + fc.boolean(), + topologyHardEdgeTypeArb ), { minLength: 0, maxLength: taskIds.length * 3 } ) @@ -320,7 +361,9 @@ function collectScopedNodeIds( return [...unresolvedNodeIds].sort(sortStrings); } - const scopedSeedIds = new Set(blueprint.contributorTaskIds ?? []); + const scopedSeedIds = new Set( + (blueprint.contributorTaskIds ?? []).filter((nodeId) => unresolvedNodeIds.has(nodeId)) + ); expandWithUnresolvedPrerequisites(scopedSeedIds, blueprint.hardEdges, unresolvedNodeIds); return [...scopedSeedIds].filter((nodeId) => unresolvedNodeIds.has(nodeId)).sort(sortStrings); } @@ -457,13 +500,13 @@ function countEdgesWithinComponent(componentNodeIds: string[], edgeKeys: string[ function materializeHardEdges( taskIds: string[], - entries: Array<[number, number, boolean]>, + entries: Array<[number, number, boolean, Extract]>, options: { allowDuplicates: boolean; allowSelfLoops: boolean } ): TopologyHardEdgeBlueprint[] { const hardEdges: TopologyHardEdgeBlueprint[] = []; const seenUndirectedPairs = new Set(); - for (const [leftIndex, rightIndex, keepDirection] of entries) { + for (const [leftIndex, rightIndex, keepDirection, edgeType] of entries) { const leftNodeId = taskIds[leftIndex] as string; const rightNodeId = taskIds[rightIndex] as string; const undirectedKey = edgeKey(leftNodeId, rightNodeId); @@ -475,7 +518,9 @@ function materializeHardEdges( } seenUndirectedPairs.add(undirectedKey); - hardEdges.push(buildHardEdge(hardEdges.length, leftNodeId, rightNodeId, keepDirection)); + hardEdges.push( + buildHardEdge(hardEdges.length, leftNodeId, rightNodeId, keepDirection, edgeType) + ); } return hardEdges; @@ -501,10 +546,12 @@ function buildHardEdge( index: number, leftNodeId: string, rightNodeId: string, - keepDirection: boolean + keepDirection: boolean, + edgeType: Extract ): TopologyHardEdgeBlueprint { return { edge_id: `hard_${index}`, + type: edgeType, source_id: keepDirection ? leftNodeId : rightNodeId, target_id: keepDirection ? rightNodeId : leftNodeId }; diff --git a/tests/pbt/properties.yaml b/tests/pbt/properties.yaml index dcb09ae..4310b61 100644 --- a/tests/pbt/properties.yaml +++ b/tests/pbt/properties.yaml @@ -24,16 +24,22 @@ properties: level: L0-L2 file: tests/pbt/topology-properties.test.ts generator: tests/pbt/generators/topology.ts::topologyBlueprintArb - law: hard_unresolved projection matches the simple undirected normalization model + law: hard_unresolved projection matches the simple undirected normalization model over depends_on and waits_for edges - id: PBT-topology-hard-goal-scope-reference kind: reference_model level: L0-L2 file: tests/pbt/topology-properties.test.ts generator: tests/pbt/generators/topology.ts::goalScopedTopologyBlueprintArb - law: hard_goal_scope projection equals contributor closure plus unresolved prerequisites + law: hard_goal_scope projection equals unresolved contributor closure plus unresolved hard prerequisites - id: PBT-topology-normalization-metamorphic kind: metamorphic level: L1-L2 file: tests/pbt/topology-properties.test.ts generator: tests/pbt/generators/topology.ts::simpleTopologyBlueprintArb law: duplicate hard edges and self-loops do not change the normalized topology surface + - id: PBT-topology-resolved-contributor-exclusion + kind: invariant + level: L1-L2 + file: tests/pbt/topology-properties.test.ts + generator: tests/pbt/generators/topology.ts::resolvedContributorScopeBlueprintArb + law: resolved contributors do not seed goal-scoped unresolved prerequisite closure diff --git a/tests/pbt/topology-properties.test.ts b/tests/pbt/topology-properties.test.ts index 1091880..bfade57 100644 --- a/tests/pbt/topology-properties.test.ts +++ b/tests/pbt/topology-properties.test.ts @@ -7,6 +7,7 @@ import { buildReferenceTopology, buildTopologyState, goalScopedTopologyBlueprintArb, + resolvedContributorScopeBlueprintArb, simpleTopologyBlueprintArb, type TopologyReferenceSummary, topologyBlueprintArb @@ -68,6 +69,23 @@ describe("property: topology analysis invariants", () => { { numRuns: 40 } ); }); + + it("P4 resolved contributors do not seed prerequisite closure", () => { + fc.assert( + fc.property(resolvedContributorScopeBlueprintArb, (blueprint) => { + const result = analyzeTopology(buildTopologyState(blueprint), { + projection: "hard_goal_scope", + goalNodeId: blueprint.goalNodeId as string + }); + + expect(result.node_count).toBe(0); + expect(result.edge_count).toBe(0); + expect(result.components).toEqual([]); + expect(result.warnings).toEqual(["scope_has_no_unresolved_nodes"]); + }), + { numRuns: 40 } + ); + }); }); function summarizeResult(result: ReturnType) { From 1999e672c75cc5ef6cd1052b5f8731cd4e2d330a Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Thu, 23 Apr 2026 21:26:34 +0900 Subject: [PATCH 05/10] test: address topology review nitpicks --- tests/helpers/analysis-golden.ts | 15 +++++++++------ tests/pbt/generators/topology.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/tests/helpers/analysis-golden.ts b/tests/helpers/analysis-golden.ts index 3b932d0..7421fe7 100644 --- a/tests/helpers/analysis-golden.ts +++ b/tests/helpers/analysis-golden.ts @@ -279,19 +279,22 @@ export async function collectAnalysisGoldenMetrics(): Promise 0) { throw new Error( `Replay-only fixture ${fixture.case.case_id} does not support setup_actions` ); } - scenarioMetrics.push( - evaluateReplayOnlyScenario(buildReplayStateFromFixture(fixture), fixture, scenario) - ); - continue; + scenarioMetrics.push(evaluateReplayOnlyScenario(replayState, fixture, scenario)); } + continue; + } + + for (const scenario of fixture.scenarios) { const workspaceRoot = await createTempWorkspace("casegraph-analysis-golden-"); try { await seedFixture(workspaceRoot, fixture); diff --git a/tests/pbt/generators/topology.ts b/tests/pbt/generators/topology.ts index 5bcf580..b54f14f 100644 --- a/tests/pbt/generators/topology.ts +++ b/tests/pbt/generators/topology.ts @@ -70,7 +70,7 @@ export const topologyBlueprintArb: fc.Arbitrary = fc caseId: `topology-prop-${taskIds.join("-")}`, tasks: taskIds.map((taskId, index) => ({ node_id: taskId, - state: states[index] as TopologyTaskState + state: readTopologyTaskState(states, index, taskId) })), hardEdges })); @@ -94,7 +94,7 @@ export const simpleTopologyBlueprintArb: fc.Arbitrary = fc caseId: `topology-simple-prop-${taskIds.join("-")}`, tasks: taskIds.map((taskId, index) => ({ node_id: taskId, - state: states[index] as TopologyTaskState + state: readTopologyTaskState(states, index, taskId) })), hardEdges })); @@ -352,6 +352,28 @@ function collectUnresolvedNodeIds(tasks: TopologyTaskBlueprint[]): Set { return new Set(tasks.filter((task) => task.state !== "done").map((task) => task.node_id)); } +function readTopologyTaskState( + states: readonly TopologyTaskState[], + index: number, + taskId: string +): TopologyTaskState { + const state = states[index]; + if (!isTopologyTaskState(state)) { + throw new Error(`Missing topology task state for ${taskId} at index ${index}`); + } + return state; +} + +function isTopologyTaskState(value: NodeState | undefined): value is TopologyTaskState { + return ( + value === "todo" || + value === "doing" || + value === "waiting" || + value === "failed" || + value === "done" + ); +} + function collectScopedNodeIds( blueprint: TopologyBlueprint, projection: "hard_unresolved" | "hard_goal_scope", From 708d1086f7e4af79b6f1988824e40cb43c1fd498 Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Thu, 23 Apr 2026 21:38:52 +0900 Subject: [PATCH 06/10] fix: align empty topology scope warnings --- packages/kernel/src/analysis-bottleneck.ts | 2 +- packages/kernel/src/analysis-bridges.ts | 2 +- packages/kernel/src/analysis-components.ts | 2 +- packages/kernel/src/analysis-cutpoints.ts | 2 +- packages/kernel/src/analysis-fragility.ts | 2 +- packages/kernel/src/analysis-topology.ts | 2 +- tests/analysis-bottleneck.test.ts | 16 +++++++++++++ tests/analysis-structure.test.ts | 26 ++++++++++++++++++++++ tests/analysis-topology.test.ts | 19 ++++++++++++++++ tests/pbt/generators/topology.ts | 9 ++++---- tests/pbt/topology-properties.test.ts | 3 +-- 11 files changed, 72 insertions(+), 13 deletions(-) diff --git a/packages/kernel/src/analysis-bottleneck.ts b/packages/kernel/src/analysis-bottleneck.ts index f8c061a..b588653 100644 --- a/packages/kernel/src/analysis-bottleneck.ts +++ b/packages/kernel/src/analysis-bottleneck.ts @@ -65,7 +65,7 @@ export function analyzeBottlenecks( stableTopologicalOrder(scopedGraph); const warnings: string[] = []; - if (scopedGraph.nodes.size === 0) { + if (goalNodeId && scopedGraph.nodes.size === 0) { warnings.push("scope_has_no_unresolved_nodes"); } diff --git a/packages/kernel/src/analysis-bridges.ts b/packages/kernel/src/analysis-bridges.ts index 22a7fce..f6cddfe 100644 --- a/packages/kernel/src/analysis-bridges.ts +++ b/packages/kernel/src/analysis-bridges.ts @@ -32,7 +32,7 @@ export function analyzeBridges( const baseComponents = collectTopologyComponents(projected.graph); const warnings = new Set(projected.graph.warnings); - if (projected.graph.nodes.size === 0) { + if (projected.projection === "hard_goal_scope" && projected.graph.nodes.size === 0) { warnings.add("scope_has_no_unresolved_nodes"); } diff --git a/packages/kernel/src/analysis-components.ts b/packages/kernel/src/analysis-components.ts index e717ab9..e28de35 100644 --- a/packages/kernel/src/analysis-components.ts +++ b/packages/kernel/src/analysis-components.ts @@ -32,7 +32,7 @@ export function analyzeComponents( })); const warnings = new Set(projected.graph.warnings); - if (projected.graph.nodes.size === 0) { + if (projected.projection === "hard_goal_scope" && projected.graph.nodes.size === 0) { warnings.add("scope_has_no_unresolved_nodes"); } diff --git a/packages/kernel/src/analysis-cutpoints.ts b/packages/kernel/src/analysis-cutpoints.ts index 1ad15ae..a8020ba 100644 --- a/packages/kernel/src/analysis-cutpoints.ts +++ b/packages/kernel/src/analysis-cutpoints.ts @@ -31,7 +31,7 @@ export function analyzeCutpoints( const baseComponents = collectTopologyComponents(projected.graph); const warnings = new Set(projected.graph.warnings); - if (projected.graph.nodes.size === 0) { + if (projected.projection === "hard_goal_scope" && projected.graph.nodes.size === 0) { warnings.add("scope_has_no_unresolved_nodes"); } diff --git a/packages/kernel/src/analysis-fragility.ts b/packages/kernel/src/analysis-fragility.ts index 9b0d561..3b02a37 100644 --- a/packages/kernel/src/analysis-fragility.ts +++ b/packages/kernel/src/analysis-fragility.ts @@ -44,7 +44,7 @@ export function analyzeFragility( ...cutpointResult.warnings ]); - if (projected.graph.nodes.size === 0) { + if (projected.projection === "hard_goal_scope" && projected.graph.nodes.size === 0) { warnings.add("scope_has_no_unresolved_nodes"); } diff --git a/packages/kernel/src/analysis-topology.ts b/packages/kernel/src/analysis-topology.ts index 2d91d58..0b21fa5 100644 --- a/packages/kernel/src/analysis-topology.ts +++ b/packages/kernel/src/analysis-topology.ts @@ -42,7 +42,7 @@ export function analyzeTopology( const cycleWitnesses = collectCycleWitnesses(projected.graph, components); const warnings = new Set(projected.graph.warnings); - if (projected.graph.nodes.size === 0) { + if (projected.projection === "hard_goal_scope" && projected.graph.nodes.size === 0) { warnings.add("scope_has_no_unresolved_nodes"); } if (cycleWitnesses.length < beta1) { diff --git a/tests/analysis-bottleneck.test.ts b/tests/analysis-bottleneck.test.ts index be6152c..99618c6 100644 --- a/tests/analysis-bottleneck.test.ts +++ b/tests/analysis-bottleneck.test.ts @@ -180,6 +180,22 @@ describe("analyzeBottlenecks", () => { expect(result.nodes).toEqual([]); expect(result.warnings).toEqual(["scope_has_no_unresolved_nodes"]); }); + + it("does not warn when hard_unresolved has no unresolved nodes", () => { + const state = buildState({ + caseId: "empty-hard-unresolved-bottleneck-case", + nodes: [ + { node_id: "task_done_a", state: "done" }, + { node_id: "task_done_b", state: "done" } + ], + edges: [{ edge_id: "e1", source_id: "task_done_a", target_id: "task_done_b" }] + }); + + const result = analyzeBottlenecks(state); + + expect(result.nodes).toEqual([]); + expect(result.warnings).toEqual([]); + }); }); function buildState(input: { caseId: string; nodes: TestNodeInput[]; edges: TestEdgeInput[] }) { diff --git a/tests/analysis-structure.test.ts b/tests/analysis-structure.test.ts index 97a25e5..5936468 100644 --- a/tests/analysis-structure.test.ts +++ b/tests/analysis-structure.test.ts @@ -230,6 +230,32 @@ describe("user-facing structure analyses", () => { expect(fragility.nodes).toEqual([]); expect(fragility.warnings).toEqual(["scope_has_no_unresolved_nodes"]); }); + + it("does not warn when hard_unresolved has no unresolved nodes", () => { + const state = buildState({ + caseId: "empty-structure-hard-unresolved-case", + nodes: [ + { node_id: "task_done_a", state: "done" }, + { node_id: "task_done_b", state: "done" } + ], + edges: [{ edge_id: "e1", source_id: "task_done_a", target_id: "task_done_b" }] + }); + + const cycles = analyzeCycles(state); + expect(cycles.warnings).toEqual([]); + + const components = analyzeComponents(state); + expect(components.warnings).toEqual([]); + + const bridges = analyzeBridges(state); + expect(bridges.warnings).toEqual([]); + + const cutpoints = analyzeCutpoints(state); + expect(cutpoints.warnings).toEqual([]); + + const fragility = analyzeFragility(state); + expect(fragility.warnings).toEqual([]); + }); }); function buildState(input: { caseId: string; nodes: TestNodeInput[]; edges: TestEdgeInput[] }) { diff --git a/tests/analysis-topology.test.ts b/tests/analysis-topology.test.ts index 1e5cd5a..0fbea74 100644 --- a/tests/analysis-topology.test.ts +++ b/tests/analysis-topology.test.ts @@ -303,6 +303,25 @@ describe("analyzeTopology", () => { expect(result.warnings).toEqual(["scope_has_no_unresolved_nodes"]); }); + it("does not warn when hard_unresolved has no unresolved nodes", () => { + const state = buildState({ + caseId: "empty-hard-unresolved-case", + nodes: [ + { node_id: "task_done_a", state: "done" }, + { node_id: "task_done_b", state: "done" } + ], + edges: [{ edge_id: "e1", source_id: "task_done_a", target_id: "task_done_b" }] + }); + + const result = analyzeTopology(state); + + expect(result.node_count).toBe(0); + expect(result.edge_count).toBe(0); + expect(result.beta_0).toBe(0); + expect(result.beta_1).toBe(0); + expect(result.warnings).toEqual([]); + }); + it("requires a goal node for hard goal scope", () => { const state = buildState({ caseId: "missing-goal-case", diff --git a/tests/pbt/generators/topology.ts b/tests/pbt/generators/topology.ts index b54f14f..eb71e07 100644 --- a/tests/pbt/generators/topology.ts +++ b/tests/pbt/generators/topology.ts @@ -248,7 +248,7 @@ export function buildTopologyState(blueprint: TopologyBlueprint) { export function buildReferenceTopology( blueprint: TopologyBlueprint, - options: { projection?: "hard_unresolved" | "hard_goal_scope"; goalNodeId?: string } = {} + options: { projection?: "hard_unresolved" | "hard_goal_scope" } = {} ): TopologyReferenceSummary { const projection = resolveProjection(blueprint, options); const unresolvedNodeIds = collectUnresolvedNodeIds(blueprint.tasks); @@ -256,7 +256,7 @@ export function buildReferenceTopology( const { edgeKeys, warnings } = collectReferenceEdges(blueprint.hardEdges, scopedNodeIds); const components = collectReferenceComponents(scopedNodeIds, edgeKeys); - if (scopedNodeIds.length === 0) { + if (projection === "hard_goal_scope" && scopedNodeIds.length === 0) { warnings.add("scope_has_no_unresolved_nodes"); } @@ -342,10 +342,9 @@ function contributionEventsFromBlueprint( function resolveProjection( blueprint: TopologyBlueprint, - options: { projection?: "hard_unresolved" | "hard_goal_scope"; goalNodeId?: string } + options: { projection?: "hard_unresolved" | "hard_goal_scope" } ): "hard_unresolved" | "hard_goal_scope" { - const goalNodeId = options.goalNodeId ?? blueprint.goalNodeId; - return options.projection ?? (goalNodeId ? "hard_goal_scope" : "hard_unresolved"); + return options.projection ?? (blueprint.goalNodeId ? "hard_goal_scope" : "hard_unresolved"); } function collectUnresolvedNodeIds(tasks: TopologyTaskBlueprint[]): Set { diff --git a/tests/pbt/topology-properties.test.ts b/tests/pbt/topology-properties.test.ts index bfade57..4636ba8 100644 --- a/tests/pbt/topology-properties.test.ts +++ b/tests/pbt/topology-properties.test.ts @@ -37,8 +37,7 @@ describe("property: topology analysis invariants", () => { goalNodeId }); const expected = buildReferenceTopology(blueprint, { - projection: "hard_goal_scope", - goalNodeId + projection: "hard_goal_scope" }); expect(summarizeResult(result)).toEqual(summarizeReference(expected)); From 1cc9ba24cf9def638c2e6b23955f2b1a2a549e69 Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Thu, 23 Apr 2026 22:27:06 +0900 Subject: [PATCH 07/10] test: guard topology property goal ids --- tests/pbt/topology-properties.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/pbt/topology-properties.test.ts b/tests/pbt/topology-properties.test.ts index 4636ba8..64131dd 100644 --- a/tests/pbt/topology-properties.test.ts +++ b/tests/pbt/topology-properties.test.ts @@ -31,7 +31,7 @@ describe("property: topology analysis invariants", () => { it("P2 hard_goal_scope matches contributor and prerequisite closure", () => { fc.assert( fc.property(goalScopedTopologyBlueprintArb, (blueprint) => { - const goalNodeId = blueprint.goalNodeId as string; + const goalNodeId = requireGoalNodeId(blueprint.goalNodeId); const result = analyzeTopology(buildTopologyState(blueprint), { projection: "hard_goal_scope", goalNodeId @@ -74,7 +74,7 @@ describe("property: topology analysis invariants", () => { fc.property(resolvedContributorScopeBlueprintArb, (blueprint) => { const result = analyzeTopology(buildTopologyState(blueprint), { projection: "hard_goal_scope", - goalNodeId: blueprint.goalNodeId as string + goalNodeId: requireGoalNodeId(blueprint.goalNodeId) }); expect(result.node_count).toBe(0); @@ -119,6 +119,13 @@ function summarizeResultWithoutWarnings(result: ReturnType["cycle_witnesses"], reference: TopologyReferenceSummary From d0276d4589a640eb7a04019b53a8d352134ca6fc Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Fri, 24 Apr 2026 06:06:33 +0900 Subject: [PATCH 08/10] test: align external topology eval warnings --- .../analysis-eval.external-local-manifest.fixture.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/fixtures/analysis-eval.external-local-manifest.fixture.json b/tests/fixtures/analysis-eval.external-local-manifest.fixture.json index d2ff34c..3c73344 100644 --- a/tests/fixtures/analysis-eval.external-local-manifest.fixture.json +++ b/tests/fixtures/analysis-eval.external-local-manifest.fixture.json @@ -11,7 +11,7 @@ "labels": { "expected_beta_0": 0, "expected_beta_1": 0, - "expected_warning_ids": ["scope_has_no_unresolved_nodes"] + "expected_warning_ids": [] } } ] @@ -27,7 +27,7 @@ "labels": { "expected_beta_0": 0, "expected_beta_1": 0, - "expected_warning_ids": ["scope_has_no_unresolved_nodes"] + "expected_warning_ids": [] } } ] @@ -43,7 +43,7 @@ "labels": { "expected_beta_0": 0, "expected_beta_1": 0, - "expected_warning_ids": ["scope_has_no_unresolved_nodes"] + "expected_warning_ids": [] } } ] @@ -59,7 +59,7 @@ "labels": { "expected_beta_0": 0, "expected_beta_1": 0, - "expected_warning_ids": ["scope_has_no_unresolved_nodes"] + "expected_warning_ids": [] } } ] From 2beb1f455e2237cef26932c31b5164c5d9be4595 Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Fri, 24 Apr 2026 06:21:25 +0900 Subject: [PATCH 09/10] test: strengthen topology warning assertions --- .../analysis-eval-manifest.fixture.json | 19 +++++++++++++---- ...-eval.external-local-manifest.fixture.json | 21 ++++++++++++------- tests/helpers/analysis-eval.ts | 4 ++++ tests/pbt/topology-properties.test.ts | 19 ++++++++++++++++- 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/tests/fixtures/analysis-eval-manifest.fixture.json b/tests/fixtures/analysis-eval-manifest.fixture.json index d477f7e..6fcbfc6 100644 --- a/tests/fixtures/analysis-eval-manifest.fixture.json +++ b/tests/fixtures/analysis-eval-manifest.fixture.json @@ -12,6 +12,7 @@ "must_include_node_ids": ["task_submit_store", "goal_release_ready"], "must_not_include_node_ids": ["task_monitor_post_release"], "expected_warning_ids": [], + "expected_warning_count": 0, "top_k_contains": ["task_submit_store"] } } @@ -28,6 +29,7 @@ "labels": { "must_include_node_ids": ["task_prepare", "task_monitor", "event_release_window"], "expected_warning_ids": [], + "expected_warning_count": 0, "top_k_contains": ["task_prepare"] } }, @@ -38,6 +40,7 @@ "labels": { "must_include_node_ids": ["task_prepare", "event_release_window"], "expected_warning_ids": [], + "expected_warning_count": 0, "top_k_contains": ["task_prepare"] } }, @@ -48,6 +51,7 @@ "labels": { "must_include_node_ids": ["task_prepare", "event_release_window"], "expected_warning_ids": [], + "expected_warning_count": 0, "top_k_contains": ["task_prepare"] } } @@ -95,6 +99,7 @@ "expected_beta_0": 3, "expected_beta_1": 1, "expected_warning_ids": [], + "expected_warning_count": 0, "expected_component_node_sets": [ ["goal_release_ready"], ["task_archive", "task_cleanup"], @@ -111,6 +116,7 @@ "expected_beta_0": 1, "expected_beta_1": 1, "expected_warning_ids": [], + "expected_warning_count": 0, "expected_component_node_sets": [ ["task_docs", "task_prepare", "task_publish", "task_review"] ] @@ -133,7 +139,8 @@ "projection": "hard_unresolved", "labels": { "expected_cycle_node_sets": [["task_prepare", "task_publish", "task_review"]], - "expected_warning_ids": [] + "expected_warning_ids": [], + "expected_warning_count": 0 } }, { @@ -147,7 +154,8 @@ ["task_archive", "task_cleanup"], ["task_docs", "task_prepare", "task_publish", "task_review"] ], - "expected_warning_ids": [] + "expected_warning_ids": [], + "expected_warning_count": 0 } }, { @@ -156,7 +164,8 @@ "projection": "hard_unresolved", "labels": { "expected_bridge_pairs": ["task_archive::task_cleanup", "task_docs::task_prepare"], - "expected_warning_ids": [] + "expected_warning_ids": [], + "expected_warning_count": 0 } }, { @@ -165,7 +174,8 @@ "projection": "hard_unresolved", "labels": { "expected_cutpoint_ids": ["task_prepare"], - "expected_warning_ids": [] + "expected_warning_ids": [], + "expected_warning_count": 0 } }, { @@ -232,6 +242,7 @@ "expected_beta_0": 1, "expected_beta_1": 0, "expected_warning_ids": [], + "expected_warning_count": 0, "expected_component_node_sets": [["task_noise_a", "task_noise_b"]] } }, diff --git a/tests/fixtures/analysis-eval.external-local-manifest.fixture.json b/tests/fixtures/analysis-eval.external-local-manifest.fixture.json index 3c73344..7e318b6 100644 --- a/tests/fixtures/analysis-eval.external-local-manifest.fixture.json +++ b/tests/fixtures/analysis-eval.external-local-manifest.fixture.json @@ -11,7 +11,8 @@ "labels": { "expected_beta_0": 0, "expected_beta_1": 0, - "expected_warning_ids": [] + "expected_warning_ids": [], + "expected_warning_count": 0 } } ] @@ -27,7 +28,8 @@ "labels": { "expected_beta_0": 0, "expected_beta_1": 0, - "expected_warning_ids": [] + "expected_warning_ids": [], + "expected_warning_count": 0 } } ] @@ -43,7 +45,8 @@ "labels": { "expected_beta_0": 0, "expected_beta_1": 0, - "expected_warning_ids": [] + "expected_warning_ids": [], + "expected_warning_count": 0 } } ] @@ -59,7 +62,8 @@ "labels": { "expected_beta_0": 0, "expected_beta_1": 0, - "expected_warning_ids": [] + "expected_warning_ids": [], + "expected_warning_count": 0 } } ] @@ -78,7 +82,8 @@ "expected_component_node_sets": [ ["task_docs", "task_prepare", "task_publish", "task_review"] ], - "expected_warning_ids": [] + "expected_warning_ids": [], + "expected_warning_count": 0 } }, { @@ -87,7 +92,8 @@ "projection": "hard_unresolved", "labels": { "expected_bridge_pairs": ["task_docs::task_prepare"], - "expected_warning_ids": [] + "expected_warning_ids": [], + "expected_warning_count": 0 } }, { @@ -96,7 +102,8 @@ "projection": "hard_unresolved", "labels": { "expected_cutpoint_ids": ["task_prepare"], - "expected_warning_ids": [] + "expected_warning_ids": [], + "expected_warning_count": 0 } } ] diff --git a/tests/helpers/analysis-eval.ts b/tests/helpers/analysis-eval.ts index cd912b3..33206cb 100644 --- a/tests/helpers/analysis-eval.ts +++ b/tests/helpers/analysis-eval.ts @@ -54,6 +54,7 @@ export interface EvalQueryLabels { must_include_node_ids?: string[]; must_not_include_node_ids?: string[]; expected_warning_ids?: string[]; + expected_warning_count?: number; expected_beta_0?: number; expected_beta_1?: number; expected_component_count?: number; @@ -528,6 +529,9 @@ function evaluatePartialLabelChecks( warnings.includes(warningId) ); } + if (labels.expected_warning_count !== undefined) { + checks.expected_warning_count = warnings.length === labels.expected_warning_count; + } applyExpectedTopologyChecks(checks, queryResult, labels); applyExpectedStructureChecks(checks, queryResult, labels); if (labels.top_k_contains) { diff --git a/tests/pbt/topology-properties.test.ts b/tests/pbt/topology-properties.test.ts index 64131dd..77ff595 100644 --- a/tests/pbt/topology-properties.test.ts +++ b/tests/pbt/topology-properties.test.ts @@ -62,7 +62,9 @@ describe("property: topology analysis invariants", () => { expect(summarizeResultWithoutWarnings(noisyResult)).toEqual( summarizeResultWithoutWarnings(baseResult) ); - expect(noisyResult.cycle_witnesses).toEqual(baseResult.cycle_witnesses); + expect(canonicalizeCycleWitnesses(noisyResult.cycle_witnesses)).toEqual( + canonicalizeCycleWitnesses(baseResult.cycle_witnesses) + ); expect(noisyResult.warnings).toEqual(noisyReference.warnings); }), { numRuns: 40 } @@ -119,6 +121,21 @@ function summarizeResultWithoutWarnings(result: ReturnType["cycle_witnesses"] +): string[] { + return cycleWitnesses + .map((witness) => + JSON.stringify({ + node_ids: [...witness.node_ids].sort((left, right) => left.localeCompare(right)), + edge_pairs: witness.edge_pairs + .map((edgePair) => [edgePair.source_id, edgePair.target_id].sort().join("::")) + .sort((left, right) => left.localeCompare(right)) + }) + ) + .sort((left, right) => left.localeCompare(right)); +} + function requireGoalNodeId(goalNodeId: string | undefined): string { if (typeof goalNodeId !== "string") { throw new Error("Invariant violation: goalNodeId must be defined"); From 62420260977d48ee8516d1c6c79a4337853b2e4f Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Fri, 24 Apr 2026 06:27:41 +0900 Subject: [PATCH 10/10] test: tighten topology eval warnings --- .../analysis-eval-manifest.fixture.json | 21 ++++++++---- tests/pbt/topology-properties.test.ts | 32 ++++++++++--------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/tests/fixtures/analysis-eval-manifest.fixture.json b/tests/fixtures/analysis-eval-manifest.fixture.json index 6fcbfc6..f00a7f0 100644 --- a/tests/fixtures/analysis-eval-manifest.fixture.json +++ b/tests/fixtures/analysis-eval-manifest.fixture.json @@ -71,6 +71,7 @@ "missing_estimates_present", "duration_path_unavailable_due_to_missing_estimates" ], + "expected_warning_count": 2, "top_k_contains": ["task_collect_quotes", "task_compare_quotes"] } }, @@ -82,7 +83,8 @@ "expected_warning_ids": [ "missing_estimates_present", "slack_unavailable_due_to_missing_estimates" - ] + ], + "expected_warning_count": 2 } } ] @@ -130,7 +132,8 @@ "labels": { "expected_beta_0": 0, "expected_beta_1": 0, - "expected_warning_ids": ["scope_has_no_unresolved_nodes"] + "expected_warning_ids": ["scope_has_no_unresolved_nodes"], + "expected_warning_count": 1 } }, { @@ -184,7 +187,8 @@ "projection": "hard_unresolved", "labels": { "top_k_contains": ["task_prepare"], - "expected_warning_ids": ["bottleneck_signal_unavailable_due_to_cycles"] + "expected_warning_ids": ["bottleneck_signal_unavailable_due_to_cycles"], + "expected_warning_count": 1 } } ] @@ -201,6 +205,7 @@ "expected_beta_0": 4, "expected_beta_1": 2, "expected_warning_ids": ["self_loop_ignored"], + "expected_warning_count": 1, "expected_component_node_sets": [ ["task_a", "task_b", "task_c", "task_d", "task_e"], ["task_forest_a", "task_forest_b"], @@ -218,7 +223,8 @@ ["task_a", "task_b", "task_c"], ["task_c", "task_d", "task_e"] ], - "expected_warning_ids": ["self_loop_ignored"] + "expected_warning_ids": ["self_loop_ignored"], + "expected_warning_count": 1 } }, { @@ -230,6 +236,7 @@ "expected_beta_0": 1, "expected_beta_1": 2, "expected_warning_ids": ["self_loop_ignored"], + "expected_warning_count": 1, "expected_component_node_sets": [["task_a", "task_b", "task_c", "task_d", "task_e"]] } }, @@ -254,7 +261,8 @@ "labels": { "expected_beta_0": 0, "expected_beta_1": 0, - "expected_warning_ids": ["scope_has_no_unresolved_nodes"] + "expected_warning_ids": ["scope_has_no_unresolved_nodes"], + "expected_warning_count": 1 } }, { @@ -265,7 +273,8 @@ "labels": { "expected_beta_0": 0, "expected_beta_1": 0, - "expected_warning_ids": ["scope_has_no_unresolved_nodes"] + "expected_warning_ids": ["scope_has_no_unresolved_nodes"], + "expected_warning_count": 1 } } ] diff --git a/tests/pbt/topology-properties.test.ts b/tests/pbt/topology-properties.test.ts index 77ff595..8c01acf 100644 --- a/tests/pbt/topology-properties.test.ts +++ b/tests/pbt/topology-properties.test.ts @@ -91,33 +91,35 @@ describe("property: topology analysis invariants", () => { function summarizeResult(result: ReturnType) { return { - node_count: result.node_count, - edge_count: result.edge_count, - beta_0: result.beta_0, - beta_1: result.beta_1, - components: result.components, + ...summarizeBase(result), warnings: result.warnings }; } function summarizeReference(reference: TopologyReferenceSummary) { return { - node_count: reference.node_count, - edge_count: reference.edge_count, - beta_0: reference.beta_0, - beta_1: reference.beta_1, - components: reference.components, + ...summarizeBase(reference), warnings: reference.warnings }; } function summarizeResultWithoutWarnings(result: ReturnType) { + return summarizeBase(result); +} + +function summarizeBase(value: { + node_count: number; + edge_count: number; + beta_0: number; + beta_1: number; + components: TComponents; +}) { return { - node_count: result.node_count, - edge_count: result.edge_count, - beta_0: result.beta_0, - beta_1: result.beta_1, - components: result.components + node_count: value.node_count, + edge_count: value.edge_count, + beta_0: value.beta_0, + beta_1: value.beta_1, + components: value.components }; }