Graph nodes gain a name; nodes are objective facts (no learner/session references)
Schema: GraphNode + ExtractedProposition carry 'name' — a short 2-6 word header, the rendering handle. Node theory made explicit: a node is an objective world-facing fact; the learner's relationship to it lives entirely in the event ledger (state labels are derived, never stored). Extractor + auditor instructions enforce it — misconceptions state the wrong idea itself as a claim, never 'the learner believes Y'. Legacy ledger lines without a name derive one from the statement on read. graph-view: labels use the name; the detail panel shows name as header and statement as body.
This commit is contained in:
@@ -104,7 +104,7 @@ export function GraphView() {
|
|||||||
// frozen layout stable for a given node set across remounts.
|
// frozen layout stable for a given node set across remounts.
|
||||||
const rand = seededRandom(node.id);
|
const rand = seededRandom(node.id);
|
||||||
graph.addNode(node.id, {
|
graph.addNode(node.id, {
|
||||||
label: node.statement,
|
label: node.name,
|
||||||
size: STATE_TOKENS[state].size,
|
size: STATE_TOKENS[state].size,
|
||||||
color: stateColors.get(state) ?? edgeColor,
|
color: stateColors.get(state) ?? edgeColor,
|
||||||
x: (rand() - 0.5) * 100,
|
x: (rand() - 0.5) * 100,
|
||||||
@@ -190,7 +190,8 @@ export function GraphView() {
|
|||||||
className="absolute left-1/2 top-6 w-80 max-w-[90%] -translate-x-1/2 rounded-xl border bg-card p-4 text-left shadow-lg"
|
className="absolute left-1/2 top-6 w-80 max-w-[90%] -translate-x-1/2 rounded-xl border bg-card p-4 text-left shadow-lg"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="text-sm font-medium leading-snug">{selected.node.statement}</div>
|
<div className="text-sm font-medium leading-snug">{selected.node.name}</div>
|
||||||
|
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
|
||||||
<div className="mt-1 text-xs uppercase tracking-wide text-muted-foreground">
|
<div className="mt-1 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
{STATE_LABELS[selected.state]}
|
{STATE_LABELS[selected.state]}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
import "./.next/dev/types/root-params.d.ts";
|
import "./.next/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -99,7 +99,18 @@ const findingsSchema = z.object({
|
|||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().describe("Local id: \"p1\", \"p2\", … — referenced by edges and attribution."),
|
id: z.string().describe("Local id: \"p1\", \"p2\", … — referenced by edges and attribution."),
|
||||||
statement: z.string().describe("The proposition as one reasoning step — a node statement."),
|
name: z.string().describe(
|
||||||
|
"A short 2-6 word name for the fact — its header, used for rendering. Not a sentence.",
|
||||||
|
),
|
||||||
|
statement: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"The proposition as one reasoning step — an OBJECTIVE, world-facing fact. " +
|
||||||
|
"Never reference the learner, the teacher, or the session: not \"the learner " +
|
||||||
|
'understands X\" but \"X\"; not \"the learner thinks Y\" but the wrong idea itself, ' +
|
||||||
|
'stated as a claim (\"Reading: Nietzsche endorsed might-makes-right social Darwinism\"), ' +
|
||||||
|
"with kind \"misconception\" carrying that it is wrong.",
|
||||||
|
),
|
||||||
aliases: z.array(z.string()).describe("Verbatim alternate phrasings from this session, if any."),
|
aliases: z.array(z.string()).describe("Verbatim alternate phrasings from this session, if any."),
|
||||||
kind: z.enum(["truth", "misconception"]).describe("misconception = a wrong idea the learner holds or was warned about."),
|
kind: z.enum(["truth", "misconception"]).describe("misconception = a wrong idea the learner holds or was warned about."),
|
||||||
treeNodeId: z.string().optional().describe("The plan-tree node id this came from, when it did."),
|
treeNodeId: z.string().optional().describe("The plan-tree node id this came from, when it did."),
|
||||||
@@ -134,7 +145,17 @@ verbatim (LEARNER / TEACHER), and structured events (QUIZ lines with the learner
|
|||||||
|
|
||||||
Report, with verbatim evidence quotes for everything:
|
Report, with verbatim evidence quotes for everything:
|
||||||
|
|
||||||
|
NODE THEORY — the graph is a set of objective facts, full stop. A node is what is true (or falsely
|
||||||
|
believed) about the world, never anything about the learner or the session. The learner's
|
||||||
|
relationship to a fact — whether they know it, missed it, or hold it as a misconception — is
|
||||||
|
expressed ENTIRELY by the quiz-outcome events you attribute; it never leaks into the node itself.
|
||||||
|
So no "the learner understands X", no "discussed in this session": the node is just X. A
|
||||||
|
misconception node states the wrong idea itself as a claim, with kind "misconception" marking it
|
||||||
|
wrong — never "the learner believes Y".
|
||||||
|
|
||||||
1. PROPOSITIONS — every distinct reasoning-step-sized proposition the session touched:
|
1. PROPOSITIONS — every distinct reasoning-step-sized proposition the session touched:
|
||||||
|
- Each carries a "name": a short 2-6 word header (the rendering handle), and a "statement": the
|
||||||
|
full fact as one objective reasoning step.
|
||||||
- Plan-tree nodes become propositions (pass their treeNodeId) — statement from the node title, enriched to a
|
- Plan-tree nodes become propositions (pass their treeNodeId) — statement from the node title, enriched to a
|
||||||
complete proposition if the prose elaborates it.
|
complete proposition if the prose elaborates it.
|
||||||
- Propositions that appeared only in prose (teacher explanations, learner questions) get fresh ids.
|
- Propositions that appeared only in prose (teacher explanations, learner questions) get fresh ids.
|
||||||
@@ -201,7 +222,9 @@ verified subset.
|
|||||||
|
|
||||||
Verify:
|
Verify:
|
||||||
- PROPOSITIONS: every evidence quote must appear (near-)verbatim in the transcript view, and the statement must
|
- PROPOSITIONS: every evidence quote must appear (near-)verbatim in the transcript view, and the statement must
|
||||||
be a faithful reading of it. Drop invented or bloated statements. Conservative: when two proposed propositions
|
be a faithful reading of it. The node theory applies: a statement must be an OBJECTIVE, world-facing fact —
|
||||||
|
reword learner- or session-relative statements ("the learner understands X" → "X") or drop them when the
|
||||||
|
objective fact is not actually backed. Drop invented or bloated statements. Conservative: when two proposed propositions
|
||||||
are really one, keep the better-stated one and drop the other.
|
are really one, keep the better-stated one and drop the other.
|
||||||
- EDGES: the evidence must actually articulate the relation, in the direction claimed (from = prerequisite).
|
- EDGES: the evidence must actually articulate the relation, in the direction claimed (from = prerequisite).
|
||||||
Drop direction-swapped or speculative edges.
|
Drop direction-swapped or speculative edges.
|
||||||
|
|||||||
+14
-2
@@ -66,7 +66,15 @@ export type EdgeType = "requires" | "supports";
|
|||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
|
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
|
||||||
id: string;
|
id: string;
|
||||||
/** Canonical proposition statement. */
|
/** Short name / header for the fact (2-6 words) — the rendering handle.
|
||||||
|
* NODE THEORY: a node is an OBJECTIVE fact, never learner- or
|
||||||
|
* session-relative. No "the learner understands…", no "this session
|
||||||
|
* covered…" — just the fact, world-facing. The learner's relationship
|
||||||
|
* to the fact lives ENTIRELY in the event ledger: state labels are
|
||||||
|
* derived from quiz outcomes, never written into the node. */
|
||||||
|
name: string;
|
||||||
|
/** Canonical proposition statement — the full fact, one reasoning step,
|
||||||
|
* also objective (no learner / teacher / session references). */
|
||||||
statement: string;
|
statement: string;
|
||||||
/** Verbatim alternate statements from sessions (merge target audit trail). */
|
/** Verbatim alternate statements from sessions (merge target audit trail). */
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
@@ -146,7 +154,11 @@ export interface LedgerEvent {
|
|||||||
export interface ExtractedProposition {
|
export interface ExtractedProposition {
|
||||||
/** Extractor-local id ("p1", …) referenced by edges + attribution. */
|
/** Extractor-local id ("p1", …) referenced by edges + attribution. */
|
||||||
id: string;
|
id: string;
|
||||||
/** Canonical one-reasoning-step statement. */
|
/** Short name / header for the fact (2-6 words) — the rendering handle. */
|
||||||
|
name: string;
|
||||||
|
/** Canonical one-reasoning-step statement — an objective, world-facing
|
||||||
|
* fact (no learner / teacher / session references; the learner's
|
||||||
|
* relationship to it is carried only by the event attribution). */
|
||||||
statement: string;
|
statement: string;
|
||||||
/** Verbatim alternate phrasings from the session (merge aliases). */
|
/** Verbatim alternate phrasings from the session (merge aliases). */
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export function mergeFindings(chatId: string, findings: SessionFindings, deps: G
|
|||||||
} else {
|
} else {
|
||||||
const graphNode: GraphNode = {
|
const graphNode: GraphNode = {
|
||||||
id: newGraphNodeId(),
|
id: newGraphNodeId(),
|
||||||
|
name: p.name,
|
||||||
statement: p.statement,
|
statement: p.statement,
|
||||||
aliases: [...p.aliases],
|
aliases: [...p.aliases],
|
||||||
kind: p.kind,
|
kind: p.kind,
|
||||||
|
|||||||
+19
-4
@@ -69,13 +69,24 @@ function readValid<T>(file: string, isValid: (v: unknown) => v is T): T[] {
|
|||||||
return readRawLines(file).filter(isValid);
|
return readRawLines(file).filter(isValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGraphNode = (v: unknown): v is GraphNode =>
|
/** Pre-name ledger line (the first extraction runs) — read tolerantly. */
|
||||||
|
type StoredGraphNode = Omit<GraphNode, "name"> & { name?: string };
|
||||||
|
|
||||||
|
const isStoredGraphNode = (v: unknown): v is StoredGraphNode =>
|
||||||
isRecord(v) &&
|
isRecord(v) &&
|
||||||
isString(v.id) &&
|
isString(v.id) &&
|
||||||
isString(v.statement) &&
|
isString(v.statement) &&
|
||||||
Array.isArray(v.aliases) &&
|
Array.isArray(v.aliases) &&
|
||||||
(v.kind === "truth" || v.kind === "misconception") &&
|
(v.kind === "truth" || v.kind === "misconception") &&
|
||||||
isString(v.createdAt);
|
isString(v.createdAt) &&
|
||||||
|
(v.name === undefined || isString(v.name));
|
||||||
|
|
||||||
|
/** Derived header for legacy node lines written before `name` existed:
|
||||||
|
* the first clause of the statement, capped — good enough to render. */
|
||||||
|
function nameFromStatement(statement: string): string {
|
||||||
|
const clause = statement.split(/[.;:—]/)[0]!.trim();
|
||||||
|
return clause.length > 60 ? `${clause.slice(0, 57)}…` : clause || statement;
|
||||||
|
}
|
||||||
|
|
||||||
const EDGE_TYPES = ["requires", "supports"] as const;
|
const EDGE_TYPES = ["requires", "supports"] as const;
|
||||||
|
|
||||||
@@ -116,9 +127,13 @@ export const graphStore: GraphStore = {
|
|||||||
appendNode(node: GraphNode): void {
|
appendNode(node: GraphNode): void {
|
||||||
appendLine(LEDGER_FILES.nodes, node);
|
appendLine(LEDGER_FILES.nodes, node);
|
||||||
},
|
},
|
||||||
/** Raw node lines in append order (ids are unique — no merging needed). */
|
/** Raw node lines in append order (ids are unique — no merging needed).
|
||||||
|
* Legacy lines without a `name` get one derived from the statement. */
|
||||||
readNodes(): GraphNode[] {
|
readNodes(): GraphNode[] {
|
||||||
return readValid(LEDGER_FILES.nodes, isGraphNode);
|
return readValid(LEDGER_FILES.nodes, isStoredGraphNode).map((n) => ({
|
||||||
|
...n,
|
||||||
|
name: n.name ?? nameFromStatement(n.statement),
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Append one edge ASSERTION; readers merge lines by (from,to,type). */
|
/** Append one edge ASSERTION; readers merge lines by (from,to,type). */
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ const FINDINGS: SessionFindings = {
|
|||||||
propositions: [
|
propositions: [
|
||||||
{
|
{
|
||||||
id: "p1",
|
id: "p1",
|
||||||
|
name: "Chain rule",
|
||||||
statement: "Chain rule intuition",
|
statement: "Chain rule intuition",
|
||||||
aliases: [],
|
aliases: [],
|
||||||
kind: "truth",
|
kind: "truth",
|
||||||
@@ -143,6 +144,7 @@ const FINDINGS: SessionFindings = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "p2",
|
id: "p2",
|
||||||
|
name: "Applying chain rule",
|
||||||
statement: "Applying the chain rule",
|
statement: "Applying the chain rule",
|
||||||
aliases: [],
|
aliases: [],
|
||||||
kind: "truth",
|
kind: "truth",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { PATHS } from "../config.ts";
|
|||||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function node(id: string, statement: string, over: Partial<GraphNode> = {}): GraphNode {
|
function node(id: string, statement: string, over: Partial<GraphNode> = {}): GraphNode {
|
||||||
return { id, statement, aliases: [], kind: "truth", createdAt: "2026-08-22T00:00:00.000Z", ...over };
|
return { id, name: statement.split(";")[0]!, statement, aliases: [], kind: "truth", createdAt: "2026-08-22T00:00:00.000Z", ...over };
|
||||||
}
|
}
|
||||||
|
|
||||||
function edge(from: string, to: string): GraphEdge {
|
function edge(from: string, to: string): GraphEdge {
|
||||||
@@ -329,6 +329,7 @@ function treeFindings(tree: ReturnType<typeof createTree>): SessionFindings {
|
|||||||
return {
|
return {
|
||||||
propositions: tree.nodes.map((n, i) => ({
|
propositions: tree.nodes.map((n, i) => ({
|
||||||
id: `p${i + 1}`,
|
id: `p${i + 1}`,
|
||||||
|
name: n.title,
|
||||||
statement: n.title,
|
statement: n.title,
|
||||||
aliases: [],
|
aliases: [],
|
||||||
kind: "truth" as const,
|
kind: "truth" as const,
|
||||||
@@ -380,6 +381,7 @@ describe("exact-match merge (merger identity resolution)", () => {
|
|||||||
const ledgers = memoryLedgers();
|
const ledgers = memoryLedgers();
|
||||||
ledgers.appendNode({
|
ledgers.appendNode({
|
||||||
id: "n_alias",
|
id: "n_alias",
|
||||||
|
name: "Differentiation",
|
||||||
statement: "Differentiation basics",
|
statement: "Differentiation basics",
|
||||||
aliases: ["the derivative"],
|
aliases: ["the derivative"],
|
||||||
kind: "truth",
|
kind: "truth",
|
||||||
|
|||||||
+15
-1
@@ -136,6 +136,7 @@ function treeFindings(tree: PlanTree): SessionFindings {
|
|||||||
return {
|
return {
|
||||||
propositions: tree.nodes.map((n, i) => ({
|
propositions: tree.nodes.map((n, i) => ({
|
||||||
id: `p${i + 1}`,
|
id: `p${i + 1}`,
|
||||||
|
name: n.title,
|
||||||
statement: n.title,
|
statement: n.title,
|
||||||
aliases: [],
|
aliases: [],
|
||||||
kind: "truth" as const,
|
kind: "truth" as const,
|
||||||
@@ -428,7 +429,7 @@ describe("the trigger helper (pendingQuizAnswers)", () => {
|
|||||||
|
|
||||||
describe("graph ledger store", () => {
|
describe("graph ledger store", () => {
|
||||||
it("round-trips lines and skips corrupt / foreign-shaped lines (strict reads)", () => {
|
it("round-trips lines and skips corrupt / foreign-shaped lines (strict reads)", () => {
|
||||||
const node = { id: "n_storetest", statement: "Store test", aliases: [], kind: "truth" as const, createdAt: "2026-08-22T00:00:00.000Z" };
|
const node = { id: "n_storetest", name: "Store test", statement: "Store test", aliases: [], kind: "truth" as const, createdAt: "2026-08-22T00:00:00.000Z" };
|
||||||
graphStore.appendNode(node);
|
graphStore.appendNode(node);
|
||||||
// A torn append and a legacy V0-shaped node (different schema) — both skipped.
|
// A torn append and a legacy V0-shaped node (different schema) — both skipped.
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
@@ -439,5 +440,18 @@ describe("graph ledger store", () => {
|
|||||||
const nodes = graphStore.readNodes().filter((n) => n.id === "n_storetest" || n.id === "legacy");
|
const nodes = graphStore.readNodes().filter((n) => n.id === "n_storetest" || n.id === "legacy");
|
||||||
assert.equal(nodes.length, 1);
|
assert.equal(nodes.length, 1);
|
||||||
assert.equal(nodes[0]!.statement, "Store test");
|
assert.equal(nodes[0]!.statement, "Store test");
|
||||||
|
assert.equal(nodes[0]!.name, "Store test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives a name for pre-name ledger lines (legacy reads)", () => {
|
||||||
|
// A full, valid node line written before `name` existed.
|
||||||
|
fs.appendFileSync(
|
||||||
|
LEDGER_FILES.nodes,
|
||||||
|
`${JSON.stringify({ id: "n_prename", statement: "Derivatives measure instantaneous change; they are limits.", aliases: [], kind: "truth", createdAt: "2026-08-22T00:00:00.000Z" })}\n`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
const node = graphStore.readNodes().find((n) => n.id === "n_prename");
|
||||||
|
assert.ok(node);
|
||||||
|
assert.equal(node.name, "Derivatives measure instantaneous change");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user