A backend can emit {"id": 10} where the schema says {"id": "10"}. Every id
consumer downstream assumes str, so one numeric id aborted the whole run at
the final merge step, after the full extraction had already been paid for.
There are three crash sites, not the one in the report:
- dedup._pick_winner's _CHUNK_SUFFIX.search(n["id"]) raises
"expected string or bytes-like object, got 'int'" (the reported
traceback), but only when the node lands in a duplicate or fuzzy group
- build_from_json's sorted(node_set) raises "'<' not supported between
instances of 'str' and 'int'" for a lone node with nothing to dedup
- the same sort, reached via the fuzzy-group path
A str() cast at the reported line therefore does not fix the common case:
a single numeric-id node never reaches _pick_winner. Coerce at ingest
instead, in _coerce_non_string_ids, called from build() before dedup (which
keys on id) and from build_from_json (the direct entry used by cli.py,
watch.py and diagnostics.py, which never goes through build()). The nested
call on the build() path is an idempotent no-op.
Edge endpoints and hyperedge members are coerced alongside the nodes on
purpose: coercing node ids alone would renumber 10 to "10" and leave every
edge pointing at the vanished 10, trading a loud crash for a silently
disconnected graph. The legacy from/to aliases are included because dedup
reads them directly (#803).
Only bool-free numeric scalars are coerced. A None, list or dict id is left
for validate_extraction to report, since str(None) == "None" would fabricate
an id that no edge references.
Closes#2326