From 92e12faeb8587da66828823dd082f776010eba1c Mon Sep 17 00:00:00 2001 From: wonhurk Date: Thu, 16 Jul 2026 19:33:52 +0900 Subject: [PATCH] fix(build): preserve leaked biomedical category instead of discarding it Biomedical semantic subagents are instructed to record a node's entity category (drug/gene/disease/protein/technology/peptide/institution/ company) in `entry_type`. Models sometimes also write the category into `file_type`, which is not a valid file_type. The existing canonicalizer collapses any unknown file_type to `concept` via _FILE_TYPE_SYNONYMS, which silences the validator warning but silently discards the category and mislabels the node as a `concept`. Recover a leaked biomedical category into `entry_type` (without overwriting an existing one) and coerce `file_type` to `document`, which matches how these patent/paper record files are actually typed. The ambiguous `technology` token (already a code-corpus synonym -> concept) is only treated as biomedical when the node carries a biomedical signal (an existing `entry_type`), so non-biomedical corpora are unaffected. Adds tests covering the recovery, the ambiguous-technology guard, and the unknown-junk fallback to `concept`. --- graphify/build.py | 31 ++++++++++++++++++++++++--- tests/test_build.py | 52 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/graphify/build.py b/graphify/build.py index caeaefdf9..bc8fce1d6 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -30,7 +30,7 @@ import networkx as nx from .ids import make_id, normalize_id as _normalize_id from .paths import default_graph_json as _default_graph_json -from .validate import validate_extraction +from .validate import validate_extraction, VALID_FILE_TYPES # Language interop families, keyed by extension, for the cross-language phantom-edge @@ -72,6 +72,20 @@ "framework": "concept", } +# Biomedical entity categories that semantic subagents are told to record in a +# node's `entry_type` (see the biomedical extraction rules in the /graphify +# skill). Models sometimes leak the category into `file_type` instead, where it +# is not a valid file_type. Unlike the generic synonyms above — which collapse +# unknown types to `concept` and discard the original token — these categories +# carry real information, so we preserve them in `entry_type` rather than lose +# them. `technology` is intentionally also present in _FILE_TYPE_SYNONYMS (it is +# ambiguous with the code-corpus sense), so it is only recovered as biomedical +# when the node already shows a biomedical signal (an existing `entry_type`). +_BIOMEDICAL_CATEGORIES = { + "drug", "gene", "disease", "protein", "technology", + "peptide", "institution", "company", +} + # Hyperedge member lists are canonically keyed `nodes` (see graphify/llm.py # extraction spec), but LLM/subagent drift and externally-supplied graph.json @@ -428,8 +442,19 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat if node.get("file_type") in (None, ""): node["file_type"] = "concept" ft = node.get("file_type", "") - if ft and ft not in {"code", "document", "paper", "image", "rationale", "concept"}: - node["file_type"] = _FILE_TYPE_SYNONYMS.get(ft, "concept") + if ft and ft not in VALID_FILE_TYPES: + # A leaked biomedical entity category (drug/gene/disease/...) carries + # real information, so preserve it in entry_type instead of + # collapsing it to `concept` and discarding it. `technology` is + # ambiguous with the code-corpus synonym, so only treat it as + # biomedical when the node already carries a biomedical signal. + if ft in _BIOMEDICAL_CATEGORIES and ( + node.get("entry_type") or ft not in _FILE_TYPE_SYNONYMS + ): + node.setdefault("entry_type", ft) + node["file_type"] = "document" + else: + node["file_type"] = _FILE_TYPE_SYNONYMS.get(ft, "concept") # Canonicalize hyperedge member lists (#1561): producers sometimes key the # member list `members`/`node_ids` instead of `nodes`. Fold aliases onto diff --git a/tests/test_build.py b/tests/test_build.py index ed87e6b11..1c9bbf9b6 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -994,3 +994,55 @@ def test_build_from_json_prunes_dangling_hyperedge_members(capsys): assert set(hes) == {"he_partial"}, "an all-dangling hyperedge must be dropped" assert hes["he_partial"]["nodes"] == ["alpha", "beta"] assert "he_all_ghost" in capsys.readouterr().err + + +def test_leaked_biomedical_category_recovered_into_entry_type(): + # #(new): biomedical semantic subagents are told to put the entity category + # (drug/gene/disease/...) in `entry_type`, but models sometimes also write + # it to `file_type`, which is not a valid file_type. Recover the category + # into entry_type and coerce file_type to a valid `document` — without + # emitting an invalid-file_type warning and without discarding the category. + ext = { + "nodes": [ + # peptide is biomedical-exclusive: recovered even without entry_type + {"id": "phlip", "label": "pHLIP", "file_type": "peptide", + "source_file": "patents/records/x.md"}, + # entry_type already correct; must not be overwritten + {"id": "mitocatch", "label": "MitoCatch", "file_type": "technology", + "entry_type": "technology", "source_file": "patents/records/y.md"}, + ], + "edges": [], + } + G = build_from_json(ext) + assert G.nodes["phlip"]["file_type"] == "document" + assert G.nodes["phlip"]["entry_type"] == "peptide" + assert G.nodes["mitocatch"]["file_type"] == "document" + assert G.nodes["mitocatch"]["entry_type"] == "technology" + + +def test_ambiguous_technology_without_signal_stays_code_synonym(): + # `technology` is ambiguous with the code-corpus synonym (-> concept). With + # no biomedical signal (no entry_type), preserve the existing behavior so + # non-biomedical corpora are unaffected. + ext = { + "nodes": [ + {"id": "n1", "label": "Some Tech", "file_type": "technology", + "source_file": "docs/a.md"}, + ], + "edges": [], + } + G = build_from_json(ext) + assert G.nodes["n1"]["file_type"] == "concept" + assert "entry_type" not in G.nodes["n1"] + + +def test_unknown_invalid_file_type_still_falls_back_to_concept(): + ext = { + "nodes": [ + {"id": "n1", "label": "Junk", "file_type": "gizmo", + "source_file": "docs/a.md"}, + ], + "edges": [], + } + G = build_from_json(ext) + assert G.nodes["n1"]["file_type"] == "concept"