What happened?
When two edges land on the same node pair with the same relation, the guard added for #2803 cannot fire, so G.add_edge() overwrites and the last write wins. Because semantic edges are always concatenated after AST edges, a semantic INFERRED edge silently overwrites an AST EXTRACTED edge and takes its source_file / source_location with it.
Expected: on a same-pair, same-relation collision, the higher-confidence edge should survive, and confidence should never be downgraded from EXTRACTED to INFERRED.
Actual: the surviving edge reports confidence: INFERRED, the semantic confidence_score, and the doc's source_file. Nothing in the output records that a deterministic AST edge existed.
Steps to reproduce
from graphify.build import build_from_json
extraction = {
"nodes": [{"id": "a", "label": "A"}, {"id": "b", "label": "B"}],
"edges": [
# AST edge first — the skill pipeline builds `ast['edges'] + sem['edges']`,
# so AST edges always come first for the same pair.
{"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED",
"confidence_score": 1.0, "source_file": "a.py", "source_location": "L724"},
# semantic edge second, SAME pair and SAME relation
{"source": "a", "target": "b", "relation": "calls", "confidence": "INFERRED",
"confidence_score": 0.95, "source_file": "some_note.md", "source_location": None},
],
}
G = build_from_json(extraction)
print(G.get_edge_data("a", "b"))
Reversing the two edges in the list does not help either, because add_edge is still last-write-wins and the caller controls the order.
Error output or graph output
surviving edge: {'relation': 'calls', 'confidence': 'INFERRED', 'confidence_score': 0.95,
'source_file': 'some_note.md', 'source_location': None}
Expected:
surviving edge: {'relation': 'calls', 'confidence': 'EXTRACTED', 'confidence_score': 1.0,
'source_file': 'a.py', 'source_location': 'L724'}
Observed on a real 2019-node corpus. api/app.py:724 is broker = MockTDAAccount(...). AST extraction emits it correctly:
$ graphify ... # AST side
api_app_build_app -- calls -> broker_mock_tda_mocktdaaccount | EXTRACTED | loc L724
But graph.json contains only this edge for that pair:
{"relation": "calls", "confidence": "INFERRED", "confidence_score": 0.95,
"source_file": "graphify-out/memory/query_20260920_232355_....md", "source_location": null}
So GRAPH_REPORT.md reports a relationship stated in plain code as an inference guessed from one of graphify's own internal memory files, and the EXTRACTED fact is unrecoverable from graph.json.
Scale on that corpus: 1 of 3701 edges for this exact case, but 27 pairs total where an EXTRACTED edge was replaced by a non-EXTRACTED one (the other 26 are covered in the follow-up issue below).
Graphify version
0.9.61. Re-verified against the v0.9.65 sources: graphify/build.py L1415-1423 and _GENERIC_RELATIONS (L64) are unchanged, so the defect is present in the latest release.
Environment
macOS 15 (darwin arm64), Python 3.12, uv tool install graphifyy, /graphify invoked from an OpenCode session.
Additional context
Related and distinct:
Suggested direction: before G.add_edge, compare the incoming and existing edges and keep the stronger fact rather than the later write, e.g. rank by EXTRACTED > INFERRED > AMBIGUOUS, and on an equal-confidence tie prefer the AST-sourced edge over a semantic one. The _GENERIC_RELATIONS logic can then layer on top of that instead of standing in for it.
What happened?
When two edges land on the same node pair with the same relation, the guard added for #2803 cannot fire, so
G.add_edge()overwrites and the last write wins. Because semantic edges are always concatenated after AST edges, a semanticINFERREDedge silently overwrites an ASTEXTRACTEDedge and takes itssource_file/source_locationwith it.Expected: on a same-pair, same-relation collision, the higher-confidence edge should survive, and confidence should never be downgraded from
EXTRACTEDtoINFERRED.Actual: the surviving edge reports
confidence: INFERRED, the semanticconfidence_score, and the doc'ssource_file. Nothing in the output records that a deterministic AST edge existed.Steps to reproduce
Reversing the two edges in the list does not help either, because
add_edgeis still last-write-wins and the caller controls the order.Error output or graph output
Expected:
Observed on a real 2019-node corpus.
api/app.py:724isbroker = MockTDAAccount(...). AST extraction emits it correctly:But
graph.jsoncontains only this edge for that pair:{"relation": "calls", "confidence": "INFERRED", "confidence_score": 0.95, "source_file": "graphify-out/memory/query_20260920_232355_....md", "source_location": null}So
GRAPH_REPORT.mdreports a relationship stated in plain code as an inference guessed from one of graphify's own internal memory files, and theEXTRACTEDfact is unrecoverable fromgraph.json.Scale on that corpus: 1 of 3701 edges for this exact case, but 27 pairs total where an
EXTRACTEDedge was replaced by a non-EXTRACTEDone (the other 26 are covered in the follow-up issue below).Graphify version
0.9.61. Re-verified against the v0.9.65 sources:
graphify/build.pyL1415-1423 and_GENERIC_RELATIONS(L64) are unchanged, so the defect is present in the latest release.Environment
macOS 15 (darwin arm64), Python 3.12,
uv tool install graphifyy,/graphifyinvoked from an OpenCode session.Additional context
Related and distinct:
referencesoverwritescallsevery time (144/144 pairs) and those calls leave the call graph #2803 (CLOSED) added the_GENERIC_RELATIONSguard, but that guard only fires when the incoming relation is generic and the existing one is not. A same-relation collision is not covered by it, and neither is a collision between two relations that are both in the set.graphify updatesilently downgrades a directed graph to undirected — betweenness becomes meaningless with no warning #2342 concernsupdateand direction, not relation/confidence selection.Suggested direction: before
G.add_edge, compare the incoming and existing edges and keep the stronger fact rather than the later write, e.g. rank byEXTRACTED > INFERRED > AMBIGUOUS, and on an equal-confidence tie prefer the AST-sourced edge over a semantic one. The_GENERIC_RELATIONSlogic can then layer on top of that instead of standing in for it.