From cc0fca11227d9e591389797709473497dffb3f83 Mon Sep 17 00:00:00 2001 From: AirRocker <264858149+AirRocker@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:54:00 -0500 Subject: [PATCH] skill: require a mandatory-ID gate when re-extracting an already-graphed doc Semantic re-extraction of a doc that already has nodes in graph.json is non-deterministic. On a large project CLAUDE.md, successive --update runs of the same unchanged-except-one-line file returned 75 nodes and then 49, silently dropping a whole documented section. Nothing errored: the loss went into build_merge and would only have surfaced later as a question the graph could no longer answer. It was caught solely because the to_json shrink guard (#479) refused the write. Adds a required three-step gate to the --update flow, in the shared references/update.md fragment plus a pointer from the core template: 1. Snapshot that file's existing node IDs from graph.json before extracting. 2. Pass them to the extraction subagent as MANDATORY must-include IDs, with the baseline node count as an explicit target. Reusing the IDs also suppresses gratuitous ID churn, which orphans saved queries and inflates the merge diff for no semantic gain. 3. Hard-gate the merge: refuse unless node count >= 90% of baseline AND zero mandatory IDs are missing. On failure, re-dispatch naming the missing IDs; two consecutive failures stop and report rather than merging a regression. Also restates that the shrink guard is the authoritative backstop: never force past it without diffing the old and new node sets and being able to name why each removed node is legitimately gone. Fragments are the edited source; graphify/skill*.md, graphify/skills/**, graphify/always_on/** and tools/skillgen/expected/** are regenerated via `python -m tools.skillgen` and `--bless`. Markdown only, no code changes. All five skillgen guards pass (--check, --audit-coverage, --schema-singleton, --monolith-roundtrip, --always-on-roundtrip). --- graphify/skill-agents.md | 2 + graphify/skill-amp.md | 2 + graphify/skill-claw.md | 2 + graphify/skill-codex.md | 2 + graphify/skill-copilot.md | 2 + graphify/skill-droid.md | 2 + graphify/skill-kilo.md | 2 + graphify/skill-kiro.md | 2 + graphify/skill-opencode.md | 2 + graphify/skill-pi.md | 2 + graphify/skill-trae.md | 2 + graphify/skill-vscode.md | 2 + graphify/skill-windows.md | 2 + graphify/skill.md | 2 + graphify/skills/agents/references/update.md | 65 +++++++++++++++++++ graphify/skills/amp/references/update.md | 65 +++++++++++++++++++ graphify/skills/claude/references/update.md | 65 +++++++++++++++++++ graphify/skills/claw/references/update.md | 65 +++++++++++++++++++ graphify/skills/codex/references/update.md | 65 +++++++++++++++++++ graphify/skills/copilot/references/update.md | 65 +++++++++++++++++++ graphify/skills/droid/references/update.md | 65 +++++++++++++++++++ graphify/skills/kilo/references/update.md | 65 +++++++++++++++++++ graphify/skills/kiro/references/update.md | 65 +++++++++++++++++++ graphify/skills/opencode/references/update.md | 65 +++++++++++++++++++ graphify/skills/pi/references/update.md | 65 +++++++++++++++++++ graphify/skills/trae/references/update.md | 65 +++++++++++++++++++ graphify/skills/vscode/references/update.md | 65 +++++++++++++++++++ graphify/skills/windows/references/update.md | 65 +++++++++++++++++++ .../expected/graphify__skill-agents.md | 2 + .../skillgen/expected/graphify__skill-amp.md | 2 + .../skillgen/expected/graphify__skill-claw.md | 2 + .../expected/graphify__skill-codex.md | 2 + .../expected/graphify__skill-copilot.md | 2 + .../expected/graphify__skill-droid.md | 2 + .../skillgen/expected/graphify__skill-kilo.md | 2 + .../skillgen/expected/graphify__skill-kiro.md | 2 + .../expected/graphify__skill-opencode.md | 2 + tools/skillgen/expected/graphify__skill-pi.md | 2 + .../skillgen/expected/graphify__skill-trae.md | 2 + .../expected/graphify__skill-vscode.md | 2 + .../expected/graphify__skill-windows.md | 2 + tools/skillgen/expected/graphify__skill.md | 2 + ...ify__skills__agents__references__update.md | 65 +++++++++++++++++++ ...aphify__skills__amp__references__update.md | 65 +++++++++++++++++++ ...ify__skills__claude__references__update.md | 65 +++++++++++++++++++ ...phify__skills__claw__references__update.md | 65 +++++++++++++++++++ ...hify__skills__codex__references__update.md | 65 +++++++++++++++++++ ...fy__skills__copilot__references__update.md | 65 +++++++++++++++++++ ...hify__skills__droid__references__update.md | 65 +++++++++++++++++++ ...phify__skills__kilo__references__update.md | 65 +++++++++++++++++++ ...phify__skills__kiro__references__update.md | 65 +++++++++++++++++++ ...y__skills__opencode__references__update.md | 65 +++++++++++++++++++ ...raphify__skills__pi__references__update.md | 65 +++++++++++++++++++ ...phify__skills__trae__references__update.md | 65 +++++++++++++++++++ ...ify__skills__vscode__references__update.md | 65 +++++++++++++++++++ ...fy__skills__windows__references__update.md | 65 +++++++++++++++++++ tools/skillgen/fragments/core/core.md | 2 + .../fragments/references/shared/update.md | 65 +++++++++++++++++++ 58 files changed, 1943 insertions(+) diff --git a/graphify/skill-agents.md b/graphify/skill-agents.md index afb4ecc12..1732e13b3 100644 --- a/graphify/skill-agents.md +++ b/graphify/skill-agents.md @@ -664,6 +664,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-amp.md b/graphify/skill-amp.md index afb4ecc12..1732e13b3 100644 --- a/graphify/skill-amp.md +++ b/graphify/skill-amp.md @@ -664,6 +664,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-claw.md b/graphify/skill-claw.md index d98865cc8..524d933f2 100644 --- a/graphify/skill-claw.md +++ b/graphify/skill-claw.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-codex.md b/graphify/skill-codex.md index 0c821a278..ac3ee9781 100644 --- a/graphify/skill-codex.md +++ b/graphify/skill-codex.md @@ -664,6 +664,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-copilot.md b/graphify/skill-copilot.md index d98865cc8..524d933f2 100644 --- a/graphify/skill-copilot.md +++ b/graphify/skill-copilot.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-droid.md b/graphify/skill-droid.md index c3815d556..8a90a9d4f 100644 --- a/graphify/skill-droid.md +++ b/graphify/skill-droid.md @@ -664,6 +664,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-kilo.md b/graphify/skill-kilo.md index dbb4658ca..dfca9ebb9 100644 --- a/graphify/skill-kilo.md +++ b/graphify/skill-kilo.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-kiro.md b/graphify/skill-kiro.md index d98865cc8..524d933f2 100644 --- a/graphify/skill-kiro.md +++ b/graphify/skill-kiro.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-opencode.md b/graphify/skill-opencode.md index cf5dae440..9b9f5b726 100644 --- a/graphify/skill-opencode.md +++ b/graphify/skill-opencode.md @@ -659,6 +659,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-pi.md b/graphify/skill-pi.md index d98865cc8..524d933f2 100644 --- a/graphify/skill-pi.md +++ b/graphify/skill-pi.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-trae.md b/graphify/skill-trae.md index b0cbeb122..467c3b7d8 100644 --- a/graphify/skill-trae.md +++ b/graphify/skill-trae.md @@ -665,6 +665,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-vscode.md b/graphify/skill-vscode.md index 3e6bc6b7b..b8c55d517 100644 --- a/graphify/skill-vscode.md +++ b/graphify/skill-vscode.md @@ -663,6 +663,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md index 574384576..abddef6e1 100644 --- a/graphify/skill-windows.md +++ b/graphify/skill-windows.md @@ -689,6 +689,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skill.md b/graphify/skill.md index d98865cc8..524d933f2 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/graphify/skills/agents/references/update.md b/graphify/skills/agents/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/agents/references/update.md +++ b/graphify/skills/agents/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/amp/references/update.md b/graphify/skills/amp/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/amp/references/update.md +++ b/graphify/skills/amp/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/claude/references/update.md b/graphify/skills/claude/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/claude/references/update.md +++ b/graphify/skills/claude/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/claw/references/update.md b/graphify/skills/claw/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/claw/references/update.md +++ b/graphify/skills/claw/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/codex/references/update.md b/graphify/skills/codex/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/codex/references/update.md +++ b/graphify/skills/codex/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/copilot/references/update.md b/graphify/skills/copilot/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/copilot/references/update.md +++ b/graphify/skills/copilot/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/droid/references/update.md b/graphify/skills/droid/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/droid/references/update.md +++ b/graphify/skills/droid/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/kilo/references/update.md b/graphify/skills/kilo/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/kilo/references/update.md +++ b/graphify/skills/kilo/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/kiro/references/update.md b/graphify/skills/kiro/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/kiro/references/update.md +++ b/graphify/skills/kiro/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/opencode/references/update.md b/graphify/skills/opencode/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/opencode/references/update.md +++ b/graphify/skills/opencode/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/pi/references/update.md b/graphify/skills/pi/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/pi/references/update.md +++ b/graphify/skills/pi/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/trae/references/update.md b/graphify/skills/trae/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/trae/references/update.md +++ b/graphify/skills/trae/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/vscode/references/update.md b/graphify/skills/vscode/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/vscode/references/update.md +++ b/graphify/skills/vscode/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/graphify/skills/windows/references/update.md b/graphify/skills/windows/references/update.md index 3632fd412..b5b16a731 100644 --- a/graphify/skills/windows/references/update.md +++ b/graphify/skills/windows/references/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skill-agents.md b/tools/skillgen/expected/graphify__skill-agents.md index afb4ecc12..1732e13b3 100644 --- a/tools/skillgen/expected/graphify__skill-agents.md +++ b/tools/skillgen/expected/graphify__skill-agents.md @@ -664,6 +664,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-amp.md b/tools/skillgen/expected/graphify__skill-amp.md index afb4ecc12..1732e13b3 100644 --- a/tools/skillgen/expected/graphify__skill-amp.md +++ b/tools/skillgen/expected/graphify__skill-amp.md @@ -664,6 +664,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-claw.md b/tools/skillgen/expected/graphify__skill-claw.md index d98865cc8..524d933f2 100644 --- a/tools/skillgen/expected/graphify__skill-claw.md +++ b/tools/skillgen/expected/graphify__skill-claw.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-codex.md b/tools/skillgen/expected/graphify__skill-codex.md index 0c821a278..ac3ee9781 100644 --- a/tools/skillgen/expected/graphify__skill-codex.md +++ b/tools/skillgen/expected/graphify__skill-codex.md @@ -664,6 +664,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-copilot.md b/tools/skillgen/expected/graphify__skill-copilot.md index d98865cc8..524d933f2 100644 --- a/tools/skillgen/expected/graphify__skill-copilot.md +++ b/tools/skillgen/expected/graphify__skill-copilot.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-droid.md b/tools/skillgen/expected/graphify__skill-droid.md index c3815d556..8a90a9d4f 100644 --- a/tools/skillgen/expected/graphify__skill-droid.md +++ b/tools/skillgen/expected/graphify__skill-droid.md @@ -664,6 +664,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-kilo.md b/tools/skillgen/expected/graphify__skill-kilo.md index dbb4658ca..dfca9ebb9 100644 --- a/tools/skillgen/expected/graphify__skill-kilo.md +++ b/tools/skillgen/expected/graphify__skill-kilo.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-kiro.md b/tools/skillgen/expected/graphify__skill-kiro.md index d98865cc8..524d933f2 100644 --- a/tools/skillgen/expected/graphify__skill-kiro.md +++ b/tools/skillgen/expected/graphify__skill-kiro.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-opencode.md b/tools/skillgen/expected/graphify__skill-opencode.md index cf5dae440..9b9f5b726 100644 --- a/tools/skillgen/expected/graphify__skill-opencode.md +++ b/tools/skillgen/expected/graphify__skill-opencode.md @@ -659,6 +659,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-pi.md b/tools/skillgen/expected/graphify__skill-pi.md index d98865cc8..524d933f2 100644 --- a/tools/skillgen/expected/graphify__skill-pi.md +++ b/tools/skillgen/expected/graphify__skill-pi.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-trae.md b/tools/skillgen/expected/graphify__skill-trae.md index b0cbeb122..467c3b7d8 100644 --- a/tools/skillgen/expected/graphify__skill-trae.md +++ b/tools/skillgen/expected/graphify__skill-trae.md @@ -665,6 +665,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-vscode.md b/tools/skillgen/expected/graphify__skill-vscode.md index 3e6bc6b7b..b8c55d517 100644 --- a/tools/skillgen/expected/graphify__skill-vscode.md +++ b/tools/skillgen/expected/graphify__skill-vscode.md @@ -663,6 +663,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill-windows.md b/tools/skillgen/expected/graphify__skill-windows.md index 574384576..abddef6e1 100644 --- a/tools/skillgen/expected/graphify__skill-windows.md +++ b/tools/skillgen/expected/graphify__skill-windows.md @@ -689,6 +689,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skill.md b/tools/skillgen/expected/graphify__skill.md index d98865cc8..524d933f2 100644 --- a/tools/skillgen/expected/graphify__skill.md +++ b/tools/skillgen/expected/graphify__skill.md @@ -667,6 +667,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/expected/graphify__skills__agents__references__update.md b/tools/skillgen/expected/graphify__skills__agents__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__agents__references__update.md +++ b/tools/skillgen/expected/graphify__skills__agents__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__amp__references__update.md b/tools/skillgen/expected/graphify__skills__amp__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__amp__references__update.md +++ b/tools/skillgen/expected/graphify__skills__amp__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__claude__references__update.md b/tools/skillgen/expected/graphify__skills__claude__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__claude__references__update.md +++ b/tools/skillgen/expected/graphify__skills__claude__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__claw__references__update.md b/tools/skillgen/expected/graphify__skills__claw__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__claw__references__update.md +++ b/tools/skillgen/expected/graphify__skills__claw__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__codex__references__update.md b/tools/skillgen/expected/graphify__skills__codex__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__codex__references__update.md +++ b/tools/skillgen/expected/graphify__skills__codex__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__copilot__references__update.md b/tools/skillgen/expected/graphify__skills__copilot__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__copilot__references__update.md +++ b/tools/skillgen/expected/graphify__skills__copilot__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__droid__references__update.md b/tools/skillgen/expected/graphify__skills__droid__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__droid__references__update.md +++ b/tools/skillgen/expected/graphify__skills__droid__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__kilo__references__update.md b/tools/skillgen/expected/graphify__skills__kilo__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__kilo__references__update.md +++ b/tools/skillgen/expected/graphify__skills__kilo__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__kiro__references__update.md b/tools/skillgen/expected/graphify__skills__kiro__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__kiro__references__update.md +++ b/tools/skillgen/expected/graphify__skills__kiro__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__opencode__references__update.md b/tools/skillgen/expected/graphify__skills__opencode__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__opencode__references__update.md +++ b/tools/skillgen/expected/graphify__skills__opencode__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__pi__references__update.md b/tools/skillgen/expected/graphify__skills__pi__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__pi__references__update.md +++ b/tools/skillgen/expected/graphify__skills__pi__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__trae__references__update.md b/tools/skillgen/expected/graphify__skills__trae__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__trae__references__update.md +++ b/tools/skillgen/expected/graphify__skills__trae__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__vscode__references__update.md b/tools/skillgen/expected/graphify__skills__vscode__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__vscode__references__update.md +++ b/tools/skillgen/expected/graphify__skills__vscode__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/expected/graphify__skills__windows__references__update.md b/tools/skillgen/expected/graphify__skills__windows__references__update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/expected/graphify__skills__windows__references__update.md +++ b/tools/skillgen/expected/graphify__skills__windows__references__update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: diff --git a/tools/skillgen/fragments/core/core.md b/tools/skillgen/fragments/core/core.md index e28910728..27e33978e 100644 --- a/tools/skillgen/fragments/core/core.md +++ b/tools/skillgen/fragments/core/core.md @@ -602,6 +602,8 @@ fi Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. +> **Re-extracting an already-graphed doc requires the mandatory-ID gate** (`references/update.md` → "REQUIRED: mandatory-ID gate for re-extracted docs"): snapshot that file's existing node IDs, pass them to the subagent as must-include, and refuse the merge if the node count drops below 90% of baseline or any ID is missing. Semantic re-extraction is non-deterministic and drops whole sections without it. + --- ## For /graphify query diff --git a/tools/skillgen/fragments/references/shared/update.md b/tools/skillgen/fragments/references/shared/update.md index 3632fd412..b5b16a731 100644 --- a/tools/skillgen/fragments/references/shared/update.md +++ b/tools/skillgen/fragments/references/shared/update.md @@ -65,6 +65,71 @@ If `code_only` is True: print `[graphify update] Code-only changes detected - sk If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A–3C pipeline as normal. +### REQUIRED: mandatory-ID gate for re-extracted docs + +Semantic re-extraction of an already-graphed doc is non-deterministic: the same file can +yield 75 nodes on one run and 49 on the next, dropping whole sections with no error. The +loss lands in `build_merge` and only surfaces later as a question the graph can no longer +answer. Apply this gate to **every changed doc/paper that already has nodes in +`graph.json`**. Do not skip it for a small edit — a one-line change re-extracts the whole +file. + +**1 — Snapshot the file's existing node IDs before extracting.** + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +TARGET = 'DOC_PATH' # substitute: the changed doc, as it appears in source_file +g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8')) +ids = sorted(n['id'] for n in g['nodes'] if str(n.get('source_file','')).endswith(TARGET)) +Path('graphify-out/.graphify_must_ids.txt').write_text('\n'.join(ids), encoding='utf-8') +print(f'baseline: {len(ids)} existing node(s) for {TARGET}') +" +``` + +A count of 0 means the file is new — skip the gate and extract normally. + +**2 — Pass the IDs to the subagent as mandatory.** Append to the prompt from +`references/extraction-spec.md`: + +- the baseline count as an explicit target ("the prior build extracted N nodes from this + file; land at approximately N — under 90% of N means you under-extracted, go back and + cover the sections you skimmed"), +- the full ID list verbatim, labelled **MANDATORY — every one of these existed in the + prior graph and MUST appear in your output, reusing the ID verbatim**, +- an instruction to verify every mandatory ID is present *before* writing CHUNK_PATH. + +Reusing the IDs also suppresses gratuitous ID churn on re-extraction, which orphans saved +queries and inflates the merge diff for no semantic gain. + +**3 — Hard-gate the merge.** After the chunk lands and before `build_merge`, require both: +node count ≥ 90% of baseline, and zero mandatory IDs missing. + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path +d = json.loads(Path('graphify-out/.graphify_chunk_01.json').read_text(encoding='utf-8')) +must = [l for l in Path('graphify-out/.graphify_must_ids.txt').read_text(encoding='utf-8').split() if l] +ids = {n['id'] for n in d['nodes']} +missing = [m for m in must if m not in ids] +ok = len(d['nodes']) >= 0.9 * len(must) and not missing +print(f'nodes={len(d[\"nodes\"])} baseline={len(must)} missing={len(missing)}') +if missing: print('MISSING:', ' '.join(missing[:20])) +print('GATE:', 'PASS' if ok else 'FAIL') +" +``` + +On **FAIL**, do not merge: re-dispatch the extraction with the same mandatory list and a +sharper completeness instruction naming the missing IDs. Two consecutive failures — stop +and report to the user rather than merging a regressed graph. + +**The `to_json` shrink guard (#479) stays authoritative.** It is the backstop, not a +nuisance. Never pass `force=True` to clear it until you have diffed the old and new node +sets and can name why each removed node is legitimately gone (file deleted, section +removed, ID renamed with a verified replacement). ID churn on a re-extracted file is a +legitimate shrink; a missing section is not. If no new files exist (only deletions), create an empty extraction so the merge step can prune: