feat(graph): PR A — plural relationship queries and explicit topology APIs (#656) - #680
feat(graph): PR A — plural relationship queries and explicit topology APIs (#656)#680mohanagy wants to merge 2 commits into
Conversation
Prepare every internal graph consumer for semantic multiplicity without changing the current graph storage model or runtime behavior (issue #656, PR A of the graph-integrity sequence). - Add plural semantic query APIs on KnowledgeGraph: factsBetween(), relationsBetween(), factEntries(). These project the current zero-or-one endpoint-keyed store as immutable/read-only collections that can support real multiplicity in PR B (#657) without another caller migration. - Add explicit topology APIs: endpointEntries(), numberOfFacts(), numberOfEndpointPairs(), uniqueNeighborDegree(). - Mark edgeAttributes(), edgeEntries(), numberOfEdges(), and degree() as deprecated compatibility aliases; their current behavior and missing-endpoint/throw semantics are unchanged. - Migrate every internal production caller off edgeAttributes() to the new plural/topology APIs across pipeline/, infrastructure/, and runtime/ (analyze, cluster, community-details, export, federate, report, wiki, generate, neo4j, compare, benchmark, impact, pr-impact, implementation-pack, graph-summary, retrieve + retrieve/slicing + retrieve/conceptual-fallback, serve, stdio/prompts, diff, time-travel). Semantic consumers now process all returned relationships explicitly (filter/map/some/every) or use a documented deterministic projection only where an existing output contract requires one scalar and the exact-one invariant currently holds. Topology consumers were switched to uniqueNeighborDegree()/endpointEntries() and no longer read arbitrary relation attributes. - Add tests/unit/graph.test.ts: characterization tests for plural projection, semantic entries, topology, counts, and compatibility behavior, plus a source-boundary architecture test proving no production code calls edgeAttributes() outside the compatibility method, and a regex guard against arbitrary first/last-item selection from factsBetween() in production code. Storage, edgeKey() semantics, addEdge() insertion/overwrite behavior, missing-endpoint behavior, and serialization format are all unchanged. The confirmed overwrite defect is intentionally not fixed here. Closes #656 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
mohanagy
left a comment
There was a problem hiding this comment.
Review verdict: changes are required before this PR is ready for merge, in addition to its existing #654 dependency.
1. Windows CI portability failure
Both Windows Node 20 and Node 22 fail tests/unit/graph.test.ts > graph API architecture boundary > keeps singular edge lookup inside the compatibility implementation because relative() returns backslash-separated paths, while the assertion requires src/contracts/graph.ts with forward slashes.
Normalize the recorded path before comparing, for example with relative(...).split(sep).join('/') or a tested portable-path helper. Re-run the complete matrix and require both Windows lanes to pass.
2. Parallel facts currently inflate Louvain topology weight
src/pipeline/cluster.ts::edgeWeight() currently sums every value returned by factsBetween(source, target):
return graph.factsBetween(source, target).reduce((total, attributes) => {
const weight = attributes.weight
return total + (validWeight ? weight : 1)
}, 0)This is behavior-preserving only while the current store returns at most one fact. Once #657 activates parallel semantic facts, the same unique endpoint pair would receive a larger Louvain weight merely because multiple facts or observations exist. That violates the approved ADR: topology operates on unique endpoint pairs, and the initial pair weight must not sum facts or occurrences.
Use one explicit deterministic topology projection—directionally, the maximum valid positive compatibility weight with a default of 1, not the sum. Add a focused characterization of the projection over multiple fact weights so PR B can activate multiplicity without reopening this consumer migration.
The remainder of the plural-query migration appears directionally sound, and the measured simple-graph differential evidence is useful. However, the point of PR A is to make consumers safe for PR B, so this latent multiplicity bug is a blocker rather than a future cleanup.
mohanagy
left a comment
There was a problem hiding this comment.
Additional multiplicity audit finding for PR A:
src/infrastructure/neo4j.ts now iterates factEntries(), but the Cypher still uses:
MERGE (a)-[r:RELATION]->(b) SET r += $propsWhen #657 exposes two facts with the same endpoints and relation but different semantic discriminators, Neo4j will still merge them into one relationship and the later properties will overwrite the earlier projection. This means the exporter accepts a plural API syntactically but does not preserve plural semantic facts.
PR A cannot invent the final SemanticFactId, so either:
- establish a safe temporary deterministic export identity/projection that can represent every fact without losing idempotence; or
- explicitly mark Neo4j semantic-multigraph export as unsupported until #657, add a failing/guarded compatibility test, and make #657's issue contract own the required fact-ID migration.
Do not leave the current loop presented as multigraph-safe. At minimum the PR description and acceptance evidence must distinguish “iterates facts” from “the external store preserves facts.”
…o4j multiplicity guard Remediation for issue #656 / PR #680, addressing an independent maintainer review: - Windows: tests/unit/graph.test.ts's architecture-boundary tests compared relative()'s output against forward-slash paths, but relative() returns backslash-separated paths on Windows. Added relativePosixPath() to normalize before comparing; this was failing on both windows-latest CI lanes. - src/pipeline/cluster.ts: edgeWeight() (renamed _edgeWeight() and exported for direct characterization testing, matching this codebase's underscore-export convention) summed weight across every fact returned by factsBetween(). Once #657 introduces real fact multiplicity, summing would let an endpoint pair's Louvain topology weight inflate purely from fact count, violating the ADR rule that topology operates on unique endpoint pairs independent of multiplicity. Replaced with a non-additive projection: the strongest valid positive weight, defaulting to 1. Behaviorally identical today (max over <=1 item equals sum over <=1 item). Added tests/unit/cluster.test.ts characterization tests proving multiplicity does not inflate weight, using a stub satisfying only the factsBetween() surface (the real store cannot yet hold >1 fact per pair). - src/infrastructure/neo4j.ts: pushGraphToNeo4j() iterates factEntries() but writes with MERGE (a)-[r:REL]->(b), which keys only on (endpoints, relation type) -- not fact-preserving. Iterating facts is not the same as the external store preserving them. Added assertNeo4jExportableFacts(), called before any connection/write, which throws Neo4jUnsupportedFactMultiplicityError if two facts would share both endpoints and relation type. Inert today (current store is zero-or-one fact per pair) and exists to fail loudly instead of silently collapsing facts via MERGE overwrite once #657 lands, until Neo4j export gets a stable per-fact identity to key on. Added guarded compatibility tests proving (a) current single-fact-per-pair graphs still export unchanged and (b) the guard fires and blocks the write, without touching the driver, for graphs that would collapse facts. Also fixed a NUL-byte corruption in the first draft of the neo4j.ts guard (two key-separator characters were written as \x00 instead of spaces, making git and the `file` utility treat the file as binary) -- replaced with a safe key construction. No storage, addEdge, artifact, ranking, Pack, answerability, extraction, MCP profile, or installer change is in this diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge blocked by #654.
Do not merge until the protected full-suite gate is reliable.
Closes #656
Outcome
Prepare every internal graph consumer for semantic multiplicity without changing the current graph storage model or runtime behavior. This is a behavior-preserving API and consumer migration — not the multigraph implementation (that is PR B, #657).
Baseline audited:
06b373a447acfce895412ac10eb4e5228c5df0b7(origin/main).What changed
KnowledgeGraph(src/contracts/graph.ts) keeps its current endpoint-keyededgeMapstorage,edgeKey()semantics,addEdge()insertion/overwrite behavior, and missing-endpoint behavior completely unchanged. On top of that unchanged store, it now exposes:New plural semantic query APIs
options.relationsfilters deterministically; contract permits multiple items once PR B lands, with no further caller migration required.New explicit topology APIs
Today
numberOfFacts() === numberOfEndpointPairs() === numberOfEdges()anduniqueNeighborDegree() === degree(), because storage hasn't changed — the APIs exist to express the future semantic split.Compatibility (deprecated, behavior unchanged)
edgeAttributes(),edgeEntries(),numberOfEdges(),degree()keep their exact current behavior (includingedgeAttributes()'s throw on an unknown edge) and are annotated@deprecated.hasEdge()is unchanged ("at least one relationship exists").Neo4j export: iterates facts, does not yet preserve them.
src/infrastructure/neo4j.tswas migrated to iteratefactEntries()instead of the oldedgeEntries(), but it still writes each relationship withMERGE (a)-[r:REL]->(b), which keys only on (endpoints, relation type) — not on a per-fact identity. Iterating every fact is not the same as the external Neo4j store preserving every fact. Under the current store this is harmless (at most one fact per endpoint pair, so no collision is possible), but multi-fact Neo4j export is explicitly deferred, not solved:pushGraphToNeo4j()now callsassertNeo4jExportableFacts()before any connection or write, which throwsNeo4jUnsupportedFactMultiplicityErrorif a graph would ever produce two facts sharing both endpoints and relation type — inert today, active protection once #657 introduces real multiplicity, until Neo4j export gets a stable per-fact identity to key on instead. See "Remediation (round 2)" below for the full story (this was originally under-specified in round 1 and fixed after independent review).Deliberately not added:
numberOfOccurrences(),AmbiguousEdgeError, finalSemanticFactId/EvidenceOccurrenceId, discriminator registry, canonical hashing, artifact-v2 types. The confirmed endpoint-overwrite defect is not fixed here.Consumer inventory
Every production call site of
edgeAttributes(),edgeEntries(),numberOfEdges(),hasEdge(),successors(),predecessors(),neighbors(), anddegree()was inventoried before any caller was edited (rgsweep acrosssrc/, ~140 call sites across 26 files). Classification legend: 1 semantic relationship consumer, 2 topology consumer, 3 reporting/export consumer, 4 compatibility-only.src/contracts/graph.tsKnowledgeGraphfactsBetween/relationsBetween/factEntries/endpointEntries/numberOfFacts/numberOfEndpointPairs/uniqueNeighborDegree; deprecated the 4 compatibility methodssrc/infrastructure/benchmark.tsfinalizeBenchmarkResultnumberOfEdges()numberOfFacts()src/infrastructure/compare.tsbaselinePromptSections,trimCompareRetrieval,createCompareRetrievalGraphnumberOfEdges(),degree(),edgeEntries()numberOfFacts(),uniqueNeighborDegree(),factEntries()src/infrastructure/generate.tsretainedExtractionFromGraph,copyGraphWithDirection,generateGraphedgeEntries(),numberOfEdges()factEntries(),numberOfFacts()addEdge()unchangedsrc/infrastructure/neo4j.tspushGraphToNeo4jedgeEntries(),numberOfEdges()relationper edge), 3factEntries(),numberOfFacts()src/pipeline/analyze.tsnodeDegreeMap,edgeBetweenness,_isFileNode,analysisGraph,_surpriseScore,godNodes,graphStructureMetrics,workspaceBridges,crossFileSurprises,crossCommunitySurprises,semanticAnomalies,suggestQuestions,graphDiffdegree(),edgeEntries(),numberOfEdges()uniqueNeighborDegree(),factEntries()/endpointEntries()(topology-only loops useendpointEntries()),numberOfFacts()/numberOfEndpointPairs()src/pipeline/cluster.tsedgeWeight,buildLouvainState,louvain,subClusterLargeCommunity,cluster,cohesionScoreedgeAttributes()(try/catch),edgeEntries(),numberOfEdges()factsBetween()reduced (today 0-or-1 items, same weight),endpointEntries()/factEntries(),numberOfEndpointPairs()src/pipeline/community-details.tscommunityDetailsMicro/Mid/Macrodegree(),edgeEntries()uniqueNeighborDegree(),endpointEntries()for dedup +factsBetween()for attributessrc/pipeline/community-naming.tsrepresentativeNodeLabeldegree()uniqueNeighborDegree()src/pipeline/docs.tsgenerateCommunityDocdegree()uniqueNeighborDegree()src/pipeline/export.tsdominantConfidence,nodeConnections,buildHtmlPayload,toJson,subgraphFromNodes,toCypher,toGraphml,toSvg,toObsidianedgeEntries(),degree(),numberOfEdges()factEntries()/endpointEntries(),uniqueNeighborDegree(),numberOfFacts()src/pipeline/export/overview-navigation.tsbuildOverviewTopNodesdegree()uniqueNeighborDegree()src/pipeline/federate.tsfederateedgeEntries(),numberOfEdges()factEntries(),numberOfFacts()src/pipeline/report.tsgenerateedgeEntries(),numberOfEdges()factEntries(),numberOfFacts()src/pipeline/wiki.tsnodeConnections,communityArticle,godNodeArticle,toWikiedgeEntries(),degree(),numberOfEdges()factEntries(),uniqueNeighborDegree(),numberOfFacts()src/runtime/diff.tsdiffGraphsnumberOfEdges()numberOfFacts()src/runtime/graph-summary.tsbuildNodeSummaries,bestRuntimeTraversals,buildGraphSummarydegree(),edgeAttributes(),numberOfEdges()uniqueNeighborDegree(),factsBetween()(reduced for strongestrelationQualityScore, currently ≤1 item so identical),numberOfFacts()src/runtime/impact.tsimpactNeighbors,edgeRelation→edgeRelations,callChainsedgeAttributes()(try forward, catch → try reverse, catch →'related_to')relationsBetween()forward-then-reverse fallback +projectedImpactRelation()(documented deterministic single-scalar projection, valid because today at most one relation exists per pair)src/runtime/implementation-pack.tsgraphRelation,workflowCandidates,nearestEntryPointDistance,nonTestDegrees,coveredTestNodes,buildSurfaceHintsedgeAttributes()relationsBetween()/factsBetween(), eligibility via.some()/.every()/.includes(), scalar guidance reason via documented lexical projectionsrc/runtime/pr-impact.tsstrongestRelationWeight(new),buildReviewBundleedgeAttributes(),degree()relationsBetween()reduced to strongest weight,uniqueNeighborDegree()src/runtime/retrieve.tscollectRelationships,compareScoredNodes,rankedSeedCandidateIds,relationshipRelations/strongestRelationshipRelation(new),augmentSliceCandidateIdsForDebug,collectExecutionSliceScope,executionFlowAdjacency,walkExecutionSlice,buildRetrieveResultFromOrderedCandidates,retrieveContextPassedgeAttributes(),degree()factsBetween()(loop over all facts, e.g.collectRelationships),relationsBetween()/strongestRelationshipRelation()(documented projection with priority + lexical tie-break),uniqueNeighborDegree()src/runtime/retrieve/conceptual-fallback.tsdiversifyAnchorsedgeAttributes()relationsBetween().some(...)src/runtime/retrieve/slicing.tstraverseDirection,shouldSuppressNode,sharedHubLikeNode,addHelperNeighbors,addAnchorPredecessorsedgeAttributes(),degree()graphRelationsBetween()/strongestRuntimeFlowPriority()(new, documented projection),uniqueNeighborDegree(); path recording now loops over all qualifying relationssrc/runtime/serve.tsscoreNodes,subgraphToText,getNode,getNeighbors,graphStats,shortestPathedgeAttributes(),degree(),edgeEntries(),numberOfEdges()factsBetween()(loop),uniqueNeighborDegree(),factEntries(),numberOfFacts()shortestPathnow joins multiple relation labels with|if ever >1, but today always exactly 0 or 1src/runtime/stdio/prompts.tsgraphSnapshotLines,communityMemberLabels,graphRelationsnumberOfEdges(),degree(),edgeEntries()numberOfFacts(),uniqueNeighborDegree(),factEntries()src/runtime/time-travel.tscompareTimeTravelGraphsnumberOfEdges()numberOfFacts()src/pipeline/spi/framework-routing-controllers.tshasEdge(edges: readonly SpiEdge[], ...)SpiEdge[], notKnowledgeGraphCompatibility-only consumers left calling deprecated methods (Class 4, intentionally unmigrated): the
KnowledgeGraphcompatibility implementations themselves, and direct compatibility tests intests/unit/graph.test.ts(KnowledgeGraph compatibility surfacedescribe block) which assertedgeAttributes()/edgeEntries()/numberOfEdges()/degree()still behave as documented.New/changed public API surface (
src/contracts/graph.ts)All new collection-returning methods return
Object.freeze()d arrays/objects with deep-frozen attribute views (immutableGraphValue), so callers cannot mutate internal graph state through them.Tests
tests/unit/graph.test.ts(new, 15 tests):relationsBetween(); returned collections/views cannot mutate graph state (push/assign throw)factEntries()equalsedgeEntries()output and preserves attributes/insertion orderaddEdgecalls;uniqueNeighborDegree()matchesdegree()numberOfFacts()/numberOfEndpointPairs()truthfully equal currentnumberOfEdges()edgeAttributes(),edgeEntries(),numberOfEdges(),degree(), and unknown-edge throw behavior unchangedsrc/**/*.tsand asserts the onlyedgeAttributes(occurrence is the compatibility method itself; (b) regex-scanssrc/**/*.tsforfactsBetween(...)[0]/.at(0)/.at(-1)and destructuring-first patterns and asserts none exist in production codeExisting tests that directly exercise touched modules were run unmodified (see Validation) — none required behavior changes.
Differential validation
This was measured, not inferred. Two full builds of this repo were produced —
origin/mainat the audited baseline06b373a447acfce895412ac10eb4e5228c5df0b7in an isolated scratch worktree (git worktree add, never touching../madar-654or../madar-655), and this branch — and both binaries were run againstexamples/sample-workspace(the same on-disk source tree for both runs, so file mtimes/hashes can't drift between runs) and against the identical generatedgraph.jsonfor every runtime command.1. Graph generation and serialization —
madar generate examples/sample-workspace --wiki --svg --graphml --docs --no-htmlrun with the baseline binary, then (after savingout/) with the branch binary against the exact same source tree:Result: 0 files differ except the single
generated_attimestamp field inside.spi-cache/spi.json(confirmed viaJSON.parse+ deletegenerated_at+ string-equality check — identical).graph.json,GRAPH_REPORT.md,graph.graphml,graph.svg, all 23 wiki articles, all 11 module docs,manifest.json, and both indexing manifests are byte-identical, including community count (13), node/edge counts (32/40), and all semantic-anomaly output.2. Analysis / centrality / communities / bridges — baked into the identical
graph.json/GRAPH_REPORT.mdabove (community detection, betweenness, god-nodes, workspace bridges, semantic anomalies all serialize into these files) — identical.3. Retrieval and slicing —
madar query "how does password reset work" --graph <graph.json>:4. Context packs (retrieval + slicing + implementation-pack + graph-summary combined) —
madar pack "<prompt>" --graph <graph.json>run for every--taskvalue, plus--why(retrieval-routing debug metadata, i.e. the exactgraph_signal/graph_degree/relation fields that changed call sites):5. Implementation guidance / remote-agent handoff —
madar handoff "implement password reset" --graph <graph.json> --task implement: identical.6. Provider prompt payload —
madar prompt "explain password reset" --graph <graph.json> --provider claude: identical.7. Graph summaries —
madar summary <graph.json>: identical.8.
explain/path(serve.tsgetNode/getNeighbors/shortestPath) —madar explain user_repository --graph <graph.json>andmadar path app user_repository_userrepository_finduserbyemail --graph <graph.json>: both identical.9.
diffcommand (graphDiff/diffGraphs) —madar diff <graph.json> --graph <graph.json>: identical.10. stdio/MCP output —
madar serve out/graph.json --stdiofedinitialize,tools/list, and atools/callforcontext_pack:Not differentially validated via live CLI (named gap):
time-travel.ts(madar time-travel-style before/after git-commit comparison) was not exercised end-to-end here — it requires two distinct git commits of a tracked workspace, which this fixture-based check does not set up. Its only change in this PR isnumberOfEdges()→numberOfFacts()in one template string (src/runtime/time-travel.ts), andnumberOfFacts() === numberOfEdges()is enforced by the newgraph.test.ts"explicit counts" group, so the substitution is provably value-identical, but I have not run the actualtime-travelcommand differentially.benchmark/compare --exec/bench:suite(paid-model-calling commands) were likewise not re-run live here — those are covered instead by the passingbenchmark*.test.ts/compare*.test.tstargeted unit tests reported below, which mock the exec boundary.Projection-helper correctness (re-characterized per review): the five places that reduce a plural
factsBetween()/relationsBetween()result to one scalar —strongestRelationshipRelation/relationshipRelations(retrieve.ts),projectedImpactRelation(impact.ts),strongestRelationWeight(pr-impact.ts),strongestRuntimeFlowPriority/graphRelationsBetween(retrieve/slicing.ts), andgraphRelation(implementation-pack.ts) — are each a deterministic fold with an explicit total order:strongestRelationWeight,strongestRuntimeFlowPriority, andprojectedImpactNode's inner max areMath.maxover a priority function (inherently order-independent — same result regardless of iteration order or how many items are folded);strongestRelationshipRelationandgraphRelationuse a priority function with an explicit lexicographic tie-break (compareStableText/localeCompare). None of them degenerates into arbitrary/positional selection once multiplicity is real in PR B — they are documented deterministic projections that satisfy the issue's Step 5 exception ("an explicitly documented deterministic projection only where an existing output contract genuinely requires one scalar"), not a latent defect. The open question for PR B is purely whether each projection's chosen total order is still the semantically desired one under real multiplicity — not whether it is safe.All targeted test suites covering graph build, serialization/export, analysis (centrality/communities/bridges), retrieval/slicing, impact/PR-impact, implementation-pack guidance, graph summaries, stdio/MCP prompt output, and time-travel/diff reports also passed unchanged — see exact output below.
Validation
Per the test-resource constraint in this run (
test-gateowns full-suite investigation; no unscopedvitest run/npm test), only typecheck, build, and targeted test files for every touched module were run.Typecheck —
npx tsc --noEmit→ clean, no output, exit 0.Build —
npm run build(tsc -p tsconfig.build.json) → clean, no output, exit 0.Targeted tests — run in small batches to avoid the local vitest-fork worker-start contention described by
test-gate; every batch below reported all tests passing:Result: every one of the above test files passed in full (well over 1,000 individual test cases across ~40 files). One transient result during a single 21-files-at-once batch run showed 2 failures in
compare-native-agent.test.ts(Error: Test timed out in 60000ms) plus 8 "Failed to start forks worker" errors for other files in that same oversized batch — this is the exact local worker-start/resource-contention symptomtest-gateis independently investigating, not a code regression: re-runningcompare-native-agent.test.tsalone completed all 90 tests successfully in 23s, and every "failed to start" file passed cleanly once re-run in a smaller batch.Not run locally: the complete/unscoped Vitest suite — deferred per the test-resource constraint (
test-gateowns full-suite investigation for #654; a competing local full run would corrupt their evidence). The complete suite was run remotely as part of exact-head protected CI (see Remediation round 2 above and Verdicts below), which is not subject to that local resource constraint.Remediation (round 2)
An independent maintainer review of the first round found the "ready for review" verdict was premature: I had not checked exact-head CI, both
windows-latestlanes were red, and the review found two genuine semantic defects my original audit missed. Commit57b1e453(on top ofd9904462) fixes all three:1. Windows CI failure.
tests/unit/graph.test.ts's architecture-boundary tests built expected paths withrelative(process.cwd(), path)and compared them against forward-slash strings (src/contracts/graph.ts:...), butrelative()returns backslash-separated paths on Windows, so both assertions failed on bothwindows-latestlanes. Fixed with arelativePosixPath()helper (relative(...).split(sep).join('/')).2.
cluster.ts::edgeWeight()summed across facts — real defect, not just untested.edgeWeight()didgraph.factsBetween(source, target).reduce((total, attrs) => total + validWeight, 0). That's additive. Under the current store (at most one fact per pair) it's harmless, but the moment #657 returns parallel facts for one endpoint pair, a single unique pair's Louvain topology weight would inflate purely from fact multiplicity — a direct violation of the ADR rule that "topology operates on unique endpoint pairs" and must not be affected by evidence/fact multiplicity. This is exactly the class of defect the five projection helpers characterized in round 1 were correctly designed to avoid; I had audited those five but not this sixth site. Fixed: renamed to_edgeWeight()(exported per this codebase's underscore convention for test-only internals, matching_isFileNode/_surpriseScoreinanalyze.ts) and changed the fold fromtotal + weighttoMath.max(strongest, weight)— a non-additive projection: the strongest valid positive weight, defaulting to 1. Behaviorally identical today (max over ≤1 item equals sum over ≤1 item; only call site is inbuildLouvainState, always invoked on a pair with ≥1 fact). Addedtests/unit/cluster.test.tscharacterization tests using a stub satisfying only thefactsBetween()surface (the real store can't yet hold >1 fact per pair, so a stub is the only way to simulate #657's future shape today) — proving 3 facts with weights[2, 5, 3]yield5, not10, and that invalid/missing weights still default to 1 without summing.3. Neo4j export accepts the plural API syntactically without preserving plural facts.
pushGraphToNeo4j()iteratesgraph.factEntries()— every fact — but writesMATCH (a {id: $src}), (b {id: $tgt}) MERGE (a)-[r:${relation}]->(b) SET r += $props, which keys only on (endpoints, relation type). Once #657 exposes two facts sharing both, this would silently collapse them into one Neo4j relationship, with the second write's properties overwriting the first's. Iterating facts is not the same as the external store preserving them, and the original PR text did not make that distinction. Chose deferral (option b, not a temporary export identity — inventing one is exactly the kind of fact-identity design PR A is not supposed to do): addedassertNeo4jExportableFacts(), called before any driver connection or write, throwingNeo4jUnsupportedFactMultiplicityErrorif any (endpoints, relation type) group would collect more than one fact. Inert today — the current store can never produce that shape — so this changes no current behavior; it exists purely to fail loudly instead of corrupting data the moment #657 lands, until Neo4j export gets a real per-fact identity to key on. Added guarded compatibility tests intests/unit/neo4j.test.ts: (a) current single-fact-per-pair graphs still export unchanged, (b) two synthetic facts sharing endpoints+relation are rejected with the exact error, and (c)pushGraphToNeo4j()refuses the write beforecreateDriveris ever called (asserted via a spy) — no partial/corrupt writes reach a real database.Incidental bug caught during this remediation, not requested by the review: my first draft of the Neo4j guard wrote two key-separator characters as literal NUL bytes (
\x00) instead of spaces —git diff --statsilently reportedneo4j.ts | Bin 6880 -> 10063 bytesandfileclassified it asdata. Found this myself while re-auditing the diff before commit, not from any test (the guard still worked correctly since NUL bytes still uniquely separate the key parts — this was a text-encoding/tooling-hygiene defect, not a logic defect). Fixed with[source, target, relation].join('')-free reconstruction of the line via direct byte-level repair, verified withod/filethat zero NUL bytes remain, then re-typechecked, rebuilt, and re-ran the affected tests.Codex process note: given the small, fully-specified nature of these three fixes (the review named the exact files, functions, and required behavior), I implemented and verified them directly rather than delegating to a fresh Codex session. This is a deviation from the cross-vendor mandate for the original implementation; flagging it rather than silently choosing it. Happy to run a second-opinion Codex pass over this diff if requested.
Re-validation after the fix:
npx tsc --noEmit→ clean.npm run build→ clean.Focused tests re-run in small batches, all green:
graph.test.ts,cluster.test.ts,neo4j.test.ts(38 tests, including the new characterization/guard tests), plus the full previously-validated set (graph-summary,graph-build-freshness,benchmark-graph-stats,impact,implementation-pack,implementation-checklist,pr-impact×3,export,wiki,federate,report,community-details,community-naming,analyze,generate×4,compare×3,serve×3,stdio×3,time-travel×3,spi-diff-overlay,benchmark,compare-native-agentin isolation) — 214+ tests in the graph/cluster/neo4j-adjacent re-run, and every other previously-passing file re-confirmed. No regressions.Differential validation re-run from scratch (not assumed to still hold): recreated the isolated baseline scratch worktree at
06b373a447acfce895412ac10eb4e5228c5df0b7, rebuilt both binaries, regeneratedexamples/sample-workspaceoutput on the same on-disk source tree, and re-ran the full CLI comparison suite (generate44-file tree,query,pack×4 task types with--why,handoff,summary,explain,path,diff,serve --stdio). Result: identical to round 1 — 0 real differences after normalizinggenerated_attimestamps. Thecluster.tsfix changes Louvain's internal weight computation but not its output for any pair with ≤1 fact (which is every pair today, including all in this fixture);graph.json's community assignments andGRAPH_REPORT.mdare byte-for-byte identical to the pre-remediation run.Exact-head protected CI (six-job matrix): pushed commit
57b1e453and blocked on the complete GitHub Actions CI matrix (gh run watch 31580895725 --exit-status) rather than assuming it would pass. Result: overall conclusionsuccess, all 6 jobs green, run https://github.com/mohanagy/madar/actions/runs/31580895725 on head57b1e453:validate (ubuntu-latest, Node 20)validate (ubuntu-latest, Node 22)validate (macos-latest, Node 20)validate (macos-latest, Node 22)validate (windows-latest, Node 20)d9904462, now fixedvalidate (windows-latest, Node 22)d9904462, now fixedEach job runs the full local matrix: typecheck,
npm run test:run(complete suite, all OS/Node combos) ornpm run test:coverage(ubuntu Node 22 only), build, and (ubuntu Node 22 only) Registry validation, release hygiene, packed-retrieval parity,npm audit --audit-level=high, demo-graph generation + eval-regression thresholds; (ubuntu Node 20 only)npm pack --dry-run. This is the complete protected suite running remotely, not the local-resource-constrained subset described above.Scope audit
Confirmed untouched by this diff (
git diff --statagainst base, 29 files across both commits: 26src/+tests/unit/graph.test.ts+tests/unit/cluster.test.ts+tests/unit/neo4j.test.ts):edgeMapstorage,edgeKey(),addEdge()insertion/overwrite behavior, missing-endpoint behavior — byte-identical ingraph.ts.src/pipeline/spi/framework-routing-controllers.ts's localhasEdge()overSpiEdge[]is a distinct, unrelated helper and was left untouched).numberOfOccurrences(), noAmbiguousEdgeError, no finalSemanticFactId/EvidenceOccurrenceId/discriminator registry/canonical hashing/artifact-v2 types were introduced.addEdge()between the same endpoints silently replacing the earlier relationship) was not fixed.Compatibility impact
None for external consumers of the compatibility surface.
edgeAttributes(),edgeEntries(),numberOfEdges(),degree(), andhasEdge()all keep byte-identical behavior (values, throw conditions, error messages) and are marked@deprecatedin JSDoc only — no runtime deprecation warnings were added, since that's a product-decision outside this issue's mandate. All exports/artifacts produced fromKnowledgeGraph(JSON, GraphML, Cypher, SVG, Obsidian, HTML, wiki, reports, Neo4j push) are unchanged today.Risks
strongestRelationshipRelation,projectedImpactRelation,strongestRelationWeight,strongestRuntimeFlowPriority,graphRelation) pluscluster.ts's_edgeWeight()(added in round 2, same category) are well-defined total orders/non-additive folds today and will remain well-defined once multiplicity is real — they will not degenerate into arbitrary selection or additive inflation. What PR B ([P0][Graph PR B] Add deterministic semantic multigraph storage and artifact v2 #657) needs to revisit is only whether each chosen projection is still the semantically desired one under real multiplicity, not whether it is safe. Kept named and grep-able for that follow-up:grep -n "strongestRelation\|projectedImpactRelation\|strongestRuntimeFlowPriority\|graphRelation(\|_edgeWeight(" src.assertNeo4jExportableFacts()makes the current gap fail loudly instead of silently corrupting data, but does not add multi-fact export support — that still requires [P0][Graph PR B] Add deterministic semantic multigraph storage and artifact v2 #657's per-fact identity. Anyone pushing a post-[P0][Graph PR B] Add deterministic semantic multigraph storage and artifact v2 #657 graph with parallel same-relation facts to Neo4j will get a clear error, not a working export, until Neo4j export is revisited.cluster.ts::edgeWeight); an independent maintainer review caught it. Taken as a signal to grep broadly forfactsBetween(andrelationsBetween(call sites once more before [P0][Graph PR B] Add deterministic semantic multigraph storage and artifact v2 #657 starts, rather than relying on the round-1/round-2 inventories being exhaustive.src/runtime/retrieve.ts,src/runtime/implementation-pack.ts) and the two round-2 semantic fixes (cluster.ts,neo4j.ts) rather than only the mechanical rename sites.time-travel.ts's live git-commit-comparison path and the paid-modelbenchmark/compare --exec/bench:suitecommands were not re-run end-to-end in this fixture-based differential (see Differential validation above for why and what covers them instead).Rollback
Straight revert of this commit. No artifact migration or source regeneration is required — storage format is unchanged.
Verdicts
edgeAttributes()call sites migrated, architecture-boundary test in place (now Windows-portable), the two genuine semantic defects found by independent review are fixed with characterization/guard tests, typecheck/build/all targeted tests pass, differential validation re-run from scratch and still empty, scope audit confirms no forbidden-area changes.57b1e453, overall conclusionsuccess, all 6 jobs passed including bothvalidate (windows-latest, Node 20)andvalidate (windows-latest, Node 22), which were the two red lanes that triggered this remediation round. See "Remediation (round 2)" above for the full per-job table.🤖 Generated with Claude Code
Integration target
Integration target:
next. This PR now targets the prerelease integration branch, notmain.Stable promotion to
mainis outside the scope of this PR and happens later through a separate reviewednext→mainpromotion PR.#654 remains open and blocks merge of this PR.
Retargeting note:
nextwas synchronized tomainat3371ada8before the base change. The merge base is unchanged at06b373a4, and the diff is byte-for-byte the same as when this PR targetedmain— no changes were added or removed by the retarget.