Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions mcp/src/lab/wfbench/workflows/wfbench-run.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 }}"
Expand Down Expand Up @@ -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 }}"
Expand Down
24 changes: 23 additions & 1 deletion vein/src/graph/schema-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand Down
24 changes: 18 additions & 6 deletions vein/src/graph/schema-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
const key = raw.trim();
Expand All @@ -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 });
Expand Down
Loading