practice: resolveTargetId + ids-first context marker (round 9 slice 1)

The mastery-trace contract: problem targets must be traceable to
propositions. resolveTargetId (pure, chains.ts) resolves a target
string to a canonical node id — id exact first, then name
case-insensitive — against the WHOLE graph (pickless sessions and
future graph-tools poses stay valid). The graph-context marker now
introduces each pick as [id] name: statement (ids were never shown to
the model before — names only); chains stay names: comprehension
context, not tags.
This commit is contained in:
amosblomqvist
2026-10-05 06:26:21 +03:00
parent 1cac08117a
commit d47b3564ce
4 changed files with 37 additions and 7 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+23
View File
@@ -1,3 +1,5 @@
import type { GraphNode } from "./contracts/graph.ts";
/**
* Selection-facing chain views — pure + browser-safe (the shell's chain
* table consumes these; the barrel exports them). Born in the practice
@@ -55,6 +57,27 @@ export function chainsForSelection(
* singleton shows its "(no premises)" marker. Pure + browser-safe.
* Unknown ids fall back to the raw id (honest, never guessed).
* Premises sort by id — the same determinism law as chainsForSelection. */
/** Resolve ONE target string to a canonical proposition id (owner round
* 9, 2026-10-05 — the mastery-trace contract): id first (exact), then
* name (case-insensitive, trimmed — names are presentational handles,
* not unique; an ambiguous name resolves to the FIRST node in store
* order). Returns null when nothing matches — the caller rejects.
* Validation set is the WHOLE graph, not the session's picks: pickless
* sessions and future graph-tools poses stay valid. */
export function resolveTargetId(
target: string,
nodes: ReadonlyArray<Pick<GraphNode, "id" | "name">>,
): string | null {
const t = target.trim();
if (t.length === 0) return null;
if (nodes.some((n) => n.id === t)) return t;
const lower = t.toLowerCase();
for (const n of nodes) {
if (n.name.trim().toLowerCase() === lower) return n.id;
}
return null;
}
export function formatChainLine(
chain: Pick<ReasoningChain, "premises" | "conclusion">,
propositions: Readonly<Record<string, { name: string }>>,
+7 -2
View File
@@ -203,11 +203,16 @@ export interface GraphContextPick {
* feeds the transcript's collapsible GraphContextCard. No more seed
* text glued into the first prompt. */
export function newGraphContextMessage(picks: readonly GraphContextPick[], chains: readonly string[]): LhMarkerMessage {
// IDS FIRST (owner round 9, 2026-10-05 — the mastery-trace contract):
// each proposition is introduced ONCE with its id, name, and statement;
// the problem tool's `target` field takes these ids (names normalize to
// ids at the seam). Chains below stay NAMES — they are comprehension
// context, not tags; names read, ids reference.
const lines = [
"Contextualization — the learner picked these propositions from their knowledge map when starting this session:",
"",
"Propositions:",
...picks.map((p) => `- ${p.name}: ${p.statement}`),
"Propositions (id — name: statement; the problem tool's target field uses these ids):",
...picks.map((p) => `- [${p.id}] ${p.name}: ${p.statement}`),
];
if (chains.length > 0) {
lines.push("", "Reasoning chains among them (premises → conclusion):", ...chains.map((c) => `- ${c}`));
+5 -3
View File
@@ -49,7 +49,9 @@ describe("newGraphContextMessage", () => {
const m = newGraphContextMessage(PICKS, ["Modus ponens → Soundness"]);
assert.equal(m.customType, "graph-context");
assert.match(m.content, /Contextualization — the learner picked these propositions/);
assert.ok(m.content.includes("- Modus ponens: If P implies Q and P holds, Q holds."));
// IDS FIRST (owner round 9): every pick carries its id — the problem
// tool's target field references these; names alone no longer.
assert.ok(m.content.includes(`- [${PICKS[0]!.id}] Modus ponens: If P implies Q and P holds, Q holds.`));
assert.ok(m.content.includes("Reasoning chains among them"));
assert.ok(m.content.includes("- Modus ponens → Soundness"));
// the UI payload is the structured pick, not the flattened text
@@ -133,8 +135,8 @@ describe("POST /api/chats — graphContext becomes message[0]", () => {
const m = record.messages[0] as { role: string; customType: string; content: string };
assert.equal(m.role, "custom");
assert.equal(m.customType, "graph-context");
assert.ok(m.content.includes("- Modus ponens: If P implies Q and P holds, Q holds."));
// the chain AMONG the picks rides the marker (formatChainLine shape)
assert.ok(m.content.includes("- [n_a] Modus ponens: If P implies Q and P holds, Q holds."));
// the chain AMONG the picks rides the marker (formatChainLine shape — names)
assert.ok(m.content.includes("- Modus ponens → Soundness"));
});