From 495f3b13f409d22cd7dfee4d3eed972176b6207e Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Fri, 4 Sep 2026 16:37:38 -0700 Subject: [PATCH 1/2] vein graph: resolve types via Schema.type before labels, exact case first On swarm38 every jarvis-typed write failed UNKNOWN_TYPE ("no such Schema in the graph") although the EvalSet Schema exists: the graph also carries legacy labels Evalset / Evaltrigger / Evalrequirement / Evaltriggeroutput with zero nodes, and resolveType matched labels case-insensitively with LIMIT 1, canonicalizing to a spelling that has no Schema. Resolve against Schema.type first, then labels, preferring an exact-case match in both. Live test registers the legacy labels and proves an EvalSet write still lands under the schema-backed label. (cherry picked from commit d1a837742f90d4a4c226febe2dc77c84d3f874dc) --- vein/src/graph/schema-resolver.test.ts | 24 +++++++++++++++++++++++- vein/src/graph/schema-resolver.ts | 24 ++++++++++++++++++------ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/vein/src/graph/schema-resolver.test.ts b/vein/src/graph/schema-resolver.test.ts index 58c4dfa22..32ed675c8 100644 --- a/vein/src/graph/schema-resolver.test.ts +++ b/vein/src/graph/schema-resolver.test.ts @@ -78,7 +78,7 @@ describe("jarvis ontology + resolver + jarvis-typed writes (live Neo4j)", { skip assert.ok(names.constraints.includes("unique_document_node_key")); }); - it("resolves types case-insensitively (labels → Schema.type), Vein types exactly", async () => { + it("resolves types case-insensitively (Schema.type → labels), Vein types exactly", async () => { assert.equal(await resolver.resolveType("evalset"), "EvalSet"); assert.equal(await resolver.resolveType(" document "), "Document"); assert.equal(await resolver.resolveType("VeinRun"), "VeinRun"); @@ -87,6 +87,28 @@ describe("jarvis ontology + resolver + jarvis-typed writes (live Neo4j)", { skip assert.equal(await resolver.resolveType("*"), null); }); + it("a LEGACY case-variant label with no Schema never shadows the real type (swarm38's Evalset/EvalSet)", async () => { + // A long-lived jarvis graph carries labels from earlier node generations + // next to the schema-backed ones (swarm38 lists Evalset/Evaltrigger/… + // with zero nodes; Neo4j 5 drops label tokens once unused, so keep one + // legacy node per label alive here — the resolver must not be fooled + // either way). + await bolt.run(`CREATE (a:Evalset {ref_id: "legacy-1"}), (b:Evaltrigger {ref_id: "legacy-2"})`); + const labels = await bolt.run(`CALL db.labels() YIELD label WHERE toLower(label) = "evalset" RETURN collect(label) AS l`); + assert.deepEqual([...(labels[0]!["l"] as string[])].sort(), ["EvalSet", "Evalset"], "both spellings are registered labels"); + resolver.invalidate(); + assert.equal(await resolver.resolveType("EvalSet"), "EvalSet"); + assert.equal(await resolver.resolveType("evalset"), "EvalSet"); + assert.equal(await resolver.resolveType("EvalTrigger"), "EvalTrigger"); + assert.ok(await resolver.schema("EvalSet"), "the EvalSet Schema resolves through the legacy label"); + // A jarvis-typed write — the exact call that failed on swarm38. + const r = await nodes.write({ type: "EvalSet", data: { id: "legacy-label-probe", name: "probe" } }, "create"); + assert.ok(r.ref_id); + const written = await bolt.run(`MATCH (n:EvalSet {id: "legacy-label-probe"}) RETURN labels(n) AS l`); + assert.ok((written[0]!["l"] as string[]).includes("EvalSet")); + assert.ok(!(written[0]!["l"] as string[]).includes("Evalset")); + }); + it("merges CHILD_OF ancestors, exposes index/domain, forces name optional", async () => { const doc = (await resolver.schema("document"))!; assert.equal(doc.type, "Document"); diff --git a/vein/src/graph/schema-resolver.ts b/vein/src/graph/schema-resolver.ts index 1548e0f99..371b167f9 100644 --- a/vein/src/graph/schema-resolver.ts +++ b/vein/src/graph/schema-resolver.ts @@ -113,7 +113,13 @@ export class SchemaResolver { /** * Canonical type for a user-supplied string, or null. Vein types must * match exactly (registry); everything else resolves case-insensitively - * against live labels, then `Schema.type`. + * against `Schema.type` first, then live labels — exact case preferred in + * both. Schema first, because a long-lived jarvis graph carries LEGACY + * labels that differ only by case (`Evalset` next to `EvalSet`, with no + * nodes and no Schema): Neo4j keeps them in `db.labels()` forever, and + * "first label that matches case-insensitively" then canonicalizes to a + * type that has no Schema, so every write fails UNKNOWN_TYPE while the + * real schema sits right there. */ async resolveType(raw: string, tx?: ManagedTransaction): Promise { const key = raw.trim(); @@ -122,15 +128,21 @@ export class SchemaResolver { const c = this.types.get(key.toLowerCase()); if (this.fresh(c)) return c.value; let out: string | null = null; - const labels = await this.rows(tx, `CALL db.labels() YIELD label WHERE toLower(label) = toLower($t) RETURN label LIMIT 1`, { t: key }); - if (labels.length) out = String(labels[0]!["label"]); + const s = await this.rows( + tx, + `MATCH (n:Schema) WHERE toLower(n.type) = toLower($t) AND (n.is_deleted IS NULL OR n.is_deleted = false) AND n.type <> "*" + RETURN n.type AS t ORDER BY CASE WHEN n.type = $t THEN 0 ELSE 1 END, n.type LIMIT 1`, + { t: key }, + ); + if (s.length) out = String(s[0]!["t"]); else { - const s = await this.rows( + const labels = await this.rows( tx, - `MATCH (n:Schema) WHERE toLower(n.type) = toLower($t) AND (n.is_deleted IS NULL OR n.is_deleted = false) AND n.type <> "*" RETURN n.type AS t LIMIT 1`, + `CALL db.labels() YIELD label WHERE toLower(label) = toLower($t) + RETURN label ORDER BY CASE WHEN label = $t THEN 0 ELSE 1 END, label LIMIT 1`, { t: key }, ); - if (s.length) out = String(s[0]!["t"]); + if (labels.length) out = String(labels[0]!["label"]); } if (out && getVeinSchema(out)) out = getVeinSchema(out)!.type; this.types.set(key.toLowerCase(), { at: Date.now(), value: out }); From 8c7bb31283701a35d2338517d17464f26c22ed80 Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Fri, 4 Sep 2026 16:37:38 -0700 Subject: [PATCH 2/2] =?UTF-8?q?lab:=20wfbench=20=E2=80=94=20a=20failed=20r?= =?UTF-8?q?oster=20write=20degrades=20to=20no-roster,=20never=20an=20error?= =?UTF-8?q?ed=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit graph/* steps return error strings (fail-soft), so a roster that did not land shows up as a missing ref_id; the neighbors hop then rejected the undefined ref_id and the run errored after the callback had already gone out (swarm38). Gate hop/edge/link on roster_ok; the record chain and result run either way and carry the write results as diagnostics. (cherry picked from commit 153d49d4259a90462833cd92a1024d493ac089ea) --- .../lab/wfbench/workflows/wfbench-run.yaml | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/mcp/src/lab/wfbench/workflows/wfbench-run.yaml b/mcp/src/lab/wfbench/workflows/wfbench-run.yaml index df93c9953..a08c4b86d 100644 --- a/mcp/src/lab/wfbench/workflows/wfbench-run.yaml +++ b/mcp/src/lab/wfbench/workflows/wfbench-run.yaml @@ -97,11 +97,23 @@ steps: node_data: "{{ roster.trigger.node_data }}" namespace: "{{ input.namespace || params.namespace }}" + # graph/* steps return an error STRING instead of throwing (a rejected + # write is fail-soft), so a roster that did not land shows up as a missing + # ref_id. Gate the hop/edge/link chain on it: without this the neighbors + # hop's schema rejects the undefined ref_id and the whole run errors — + # after the callback already went out (observed live on swarm38). + - id: roster_ok + type: if + depends: trig + config: + cond: "{{ evalset.ref_id && trig.ref_id }}" + # hop_check_trigger_exists + guard_first_run: prior triggers ⇒ HAS_TRIGGER, # none ⇒ HAS_BASELINE_TRIGGER. - id: hop type: graph/graph-neighbors - depends: trig + depends: roster_ok + when: true config: ref_id: "{{ evalset.ref_id }}" edge_type: [HAS_TRIGGER, HAS_BASELINE_TRIGGER] @@ -352,9 +364,11 @@ steps: judge_model: "{{ params.judgeModel }}" # ── record (58312's twin) ───────────────────────────────────────────── + # Depends on the gate too, so it runs whether or not the roster landed + # (a skipped `link` alone would skip-propagate). - id: chain type: wfbench/build-eval-output - depends: [scores, link] + depends: [scores, link, roster_ok] config: task_slug: "{{ task.task_slug }}" scores: "{{ scores }}" @@ -432,6 +446,8 @@ steps: n_materials: "{{ mats.n_materials }}" material_warnings: "{{ mats.warnings }}" graph: + roster_ok: "{{ roster_ok }}" + evalset: "{{ evalset }}" evalset_ref_id: "{{ evalset.ref_id }}" trigger_ref_id: "{{ trig.ref_id }}" trigger_id: "{{ roster.trigger_id }}"