MVP M4: SysML metamodel + validator + dependency graph + UI surfacing

apps/web/lib/sysml — pure engine (no UI deps)
- model.ts: canonical types ported from phase-0 (Block, Property,
  Association, Constraint, Requirement, SysMLModel)
- validate.ts: 12 rules grouped Structural (S1–S5) / Semantic (M1–M5) /
  Traceability (T1–T2). Pure function returning element-anchored issues.
  Cycle detection via DFS with canonical-key dedup. T3 deferred to Phase 1.5
  (depends on narrative chip references).
- depgraph.ts: directed graph over Blocks / Properties / Constraints /
  Requirements with `dependentsOf()` and `neighborhood()` for impact analysis
  (substrate for M7).
- fromFixture.ts: loose FixtureData → strict SysMLModel converter (interim
  until M5 unifies state).
- breaks.ts: 8 named model corruptions (S1/S2/S3/M2/M3/M5/T1/T2) for
  ?break=... demonstrations.

UI integration in EditorShell + LeftRail + DiagramCanvas + IssuesPanel
- Floating IssuesPanel (bottom-right) with severity-grouped counts and
  collapse. Click an issue to focus its anchor across the rail and diagram.
- LeftRail: severity dots on each block in the Model section and on each
  requirement entry. Tooltips show full issue text.
- DiagramCanvas: red/blue/dotted ring around offending blocks via
  issuesByElement → BlockNode.data.issueSeverity.
- ?break=S1-dangling,M2-cycle,M5-dup-property,... query param injects
  corruptions for verification of the "done when" criterion.
- Suspense boundary added at the editor route for useSearchParams.

Two pre-existing TipTap typing fixes (editor.storage cast through unknown).
This commit is contained in:
2026-04-28 23:22:24 +02:00
parent a0566ce64c
commit 2052a280b1
14 changed files with 1131 additions and 45 deletions

View File

@@ -0,0 +1,147 @@
// Known model corruptions for demonstrating the validator end-to-end.
//
// Each "break" returns a model variant that should fire one specific rule
// (or a small cluster) without firing others. Used by the editor's `?break=...`
// query param to make the validator's behavior visible.
import type { SysMLModel } from "./model";
export type BreakName =
| "S1-dangling"
| "S2-dangling-constraint"
| "S3-untraced-satisfier"
| "M2-cycle"
| "M3-multi-system"
| "M5-dup-property"
| "T1-untraced-req"
| "T2-unused-block";
export const BREAKS: Record<BreakName, { label: string; description: string; apply: (m: SysMLModel) => SysMLModel }> = {
"S1-dangling": {
label: "S1 — dangling association endpoint",
description: "Add an association whose target block doesn't exist.",
apply(m) {
return {
...m,
associations: [
...m.associations,
{ id: "a-bad", fromBlockId: "aristotle", toBlockId: "ghost-block", label: "haunts", kind: "association" },
],
};
},
},
"S2-dangling-constraint": {
label: "S2 — constraint applies to unknown block",
description: "Add a constraint pointing at a block that doesn't exist.",
apply(m) {
return {
...m,
constraints: [
...m.constraints,
{ id: "c-bad", label: "Phantom rule", expression: "{}", appliesTo: ["ghost-block"] },
],
};
},
},
"S3-untraced-satisfier": {
label: "S3 — requirement satisfied by unknown block",
description: "Reroute REQ-001 to a non-existent block.",
apply(m) {
return {
...m,
requirements: m.requirements.map(r =>
r.tag === "REQ-001" ? { ...r, relations: [{ kind: "satisfy" as const, blockId: "ghost-block" }] } : r
),
};
},
},
"M2-cycle": {
label: "M2 — cyclic composition",
description: "Add a composition Assignment → Course (the inverse of the existing Course → Assignment).",
apply(m) {
return {
...m,
associations: [
...m.associations,
{
id: "a-cycle",
fromBlockId: "assignment",
toBlockId: "course",
label: "wraps",
kind: "composition",
},
],
};
},
},
"M3-multi-system": {
label: "M3 — two System-of-Interest blocks",
description: "Mark both Aristotle and Course as kind=system.",
apply(m) {
return {
...m,
blocks: m.blocks.map(b =>
b.id === "aristotle" || b.id === "course" ? { ...b, kind: "system" as const, stereotypes: ["system"] } : b
),
};
},
},
"M5-dup-property": {
label: "M5 — duplicate property name in a block",
description: "Add a second `interaction_style` property to Aristotle.",
apply(m) {
return {
...m,
blocks: m.blocks.map(b =>
b.id === "aristotle"
? {
...b,
properties: [
...b.properties,
{ id: "p-dup", name: "interaction_style", type: { kind: "string" as const }, multiplicity: "0..1" as const },
],
}
: b
),
};
},
},
"T1-untraced-req": {
label: "T1 — requirement with no satisfier",
description: "Already present in the fixture (REQ-003 has no satisfy relation).",
apply(m) {
// No mutation needed; the fixture already triggers T1 on REQ-003.
return m;
},
},
"T2-unused-block": {
label: "T2 — block with no incoming references",
description: "Add a Tutor block that nothing connects to.",
apply(m) {
return {
...m,
blocks: [
...m.blocks,
{
id: "tutor-orphan",
label: "TutorOrphan",
kind: "block",
stereotypes: ["block"],
properties: [],
},
],
};
},
},
};
export function applyBreaks(model: SysMLModel, names: BreakName[]): SysMLModel {
return names.reduce((m, name) => BREAKS[name].apply(m), model);
}

View File

@@ -0,0 +1,150 @@
// Dependency graph over a SysML model.
//
// Nodes: every Block, Property, Constraint, Requirement.
// Edges encode the structural and traceability relationships from
// docs/sysml-modeling.md §8.
//
// Used by the UI for cross-surface highlighting now (M4) and by Socrates'
// impact analysis later (M7).
import type { SysMLModel } from "./model";
export type DepNodeKind = "block" | "property" | "constraint" | "requirement";
export interface DepNode {
id: string; // unique within graph; for properties: `${blockId}.${propertyId}`
kind: DepNodeKind;
label: string;
}
export type DepEdgeKind =
| "contains" // block → property
| "relates" // block → block (per association.kind)
| "applies-to" // constraint → block
| "satisfies" // requirement → block
| "derives-from"; // requirement → requirement
export interface DepEdge {
from: string;
to: string;
kind: DepEdgeKind;
/** For "relates" edges, the underlying association kind. */
associationKind?: string;
}
export interface DepGraph {
nodes: Map<string, DepNode>;
/** Adjacency list, indexed by `from`. */
out: Map<string, DepEdge[]>;
/** Reverse adjacency, indexed by `to`. Useful for "what depends on this?". */
in: Map<string, DepEdge[]>;
}
export function buildDepGraph(model: SysMLModel): DepGraph {
const nodes = new Map<string, DepNode>();
const out = new Map<string, DepEdge[]>();
const inEdges = new Map<string, DepEdge[]>();
function addNode(node: DepNode) {
nodes.set(node.id, node);
if (!out.has(node.id)) out.set(node.id, []);
if (!inEdges.has(node.id)) inEdges.set(node.id, []);
}
function addEdge(edge: DepEdge) {
const o = out.get(edge.from);
if (o) o.push(edge);
else out.set(edge.from, [edge]);
const i = inEdges.get(edge.to);
if (i) i.push(edge);
else inEdges.set(edge.to, [edge]);
}
for (const b of model.blocks) {
addNode({ id: b.id, kind: "block", label: b.label });
for (const p of b.properties) {
const pid = `${b.id}.${p.id}`;
addNode({ id: pid, kind: "property", label: p.name });
addEdge({ from: b.id, to: pid, kind: "contains" });
}
}
for (const a of model.associations) {
addEdge({
from: a.fromBlockId,
to: a.toBlockId,
kind: "relates",
associationKind: a.kind,
});
}
for (const c of model.constraints) {
addNode({ id: c.id, kind: "constraint", label: c.label });
for (const target of c.appliesTo) {
addEdge({ from: c.id, to: target, kind: "applies-to" });
}
}
for (const r of model.requirements) {
addNode({ id: r.id, kind: "requirement", label: r.tag });
for (const rel of r.relations) {
if (rel.kind === "satisfy") {
addEdge({ from: r.id, to: rel.blockId, kind: "satisfies" });
} else if (rel.kind === "derive") {
addEdge({ from: r.id, to: rel.fromReqId, kind: "derives-from" });
}
}
}
return { nodes, out, in: inEdges };
}
/**
* BFS the dep graph downstream from `seedId`, returning the closure (excluding
* the seed itself by default). Use for "if I change X, what's affected?".
*/
export function dependentsOf(graph: DepGraph, seedId: string): Set<string> {
const visited = new Set<string>();
const queue: string[] = [seedId];
while (queue.length) {
const cur = queue.shift()!;
const out = graph.out.get(cur) ?? [];
for (const edge of out) {
if (!visited.has(edge.to)) {
visited.add(edge.to);
queue.push(edge.to);
}
}
// Reverse direction too — things that depend ON this element should also
// appear in the closure (an Assumption linked to this block, etc., once
// those are added in M8).
const inEdges = graph.in.get(cur) ?? [];
for (const edge of inEdges) {
if (!visited.has(edge.from)) {
visited.add(edge.from);
queue.push(edge.from);
}
}
}
visited.delete(seedId);
return visited;
}
export function neighborhood(graph: DepGraph, seedId: string, radius = 2): Set<string> {
const visited = new Set<string>([seedId]);
let frontier = new Set<string>([seedId]);
for (let i = 0; i < radius; i++) {
const next = new Set<string>();
for (const id of frontier) {
for (const e of graph.out.get(id) ?? []) {
if (!visited.has(e.to)) { visited.add(e.to); next.add(e.to); }
}
for (const e of graph.in.get(id) ?? []) {
if (!visited.has(e.from)) { visited.add(e.from); next.add(e.from); }
}
}
frontier = next;
}
visited.delete(seedId);
return visited;
}

View File

@@ -0,0 +1,88 @@
// Convert the loose Aristotle FixtureData (M1 prototype port) into a strict
// SysMLModel that the validator and dep-graph can operate on.
import type {
SysMLModel,
Block,
Association,
Constraint,
Requirement,
AssociationKind,
} from "./model";
import type { FixtureData } from "../fixtures/aristotle";
export function fromFixture(data: FixtureData): SysMLModel {
const constraintBlocks = data.blocks.filter(b => b.kind === "constraint");
const constraints: Constraint[] = constraintBlocks.map(b => ({
id: b.id,
label: b.label,
expression: "{ tenancy = institutional }",
// The fixture infers "what does this constraint apply to?" from its
// outgoing/incoming `kind: "constraint"` associations.
appliesTo: data.associations
.filter(a => a.kind === "constraint" && (a.from === b.id || a.to === b.id))
.map(a => (a.from === b.id ? a.to : a.from)),
}));
const blocks: Block[] = data.blocks
.filter(b => b.kind !== "constraint")
.map(b => ({
id: b.id,
label: b.label,
kind: b.kind,
stereotypes: [b.kind],
properties: b.properties.map((name, i) => ({
id: `p${i + 1}`,
name,
type: { kind: "string" } as const,
multiplicity: "0..1" as const,
})),
}));
const associations: Association[] = data.associations
.filter(a => a.kind !== "constraint")
.map(a => ({
id: a.id,
fromBlockId: a.from,
toBlockId: a.to,
label: a.label,
kind: mapAssocKind(a.kind),
}));
// Requirements come from the narrative; the fixture chips reference REQ-001..003
// but the requirements themselves aren't stored. Fabricate them here so the
// validator has something meaningful to operate on.
const requirements: Requirement[] = [
{
id: "req-001",
tag: "REQ-001",
text: "Must never output a complete solution to a graded problem.",
relations: [{ kind: "satisfy", blockId: "aristotle" }],
},
{
id: "req-002",
tag: "REQ-002",
text: "Response latency under 1.2s P50 to preserve flow.",
relations: [{ kind: "satisfy", blockId: "aristotle" }],
},
{
id: "req-003",
tag: "REQ-003",
text: "Operates within FERPA boundaries; coursework never leaves institutional tenancy.",
// Intentionally untraced in the fixture — see the rail's "untraced" dot
relations: [],
},
];
return {
blocks,
associations,
constraints,
requirements,
};
}
function mapAssocKind(k: "association" | "composition" | "constraint"): AssociationKind {
if (k === "constraint") return "constraintApplies";
return k;
}

View File

@@ -0,0 +1,75 @@
// Canonical SysML metamodel types — adapted from phase-0/src/types.ts
// (the Phase 0 validation that ran 10/10 against the corpus).
//
// See docs/sysml-modeling.md §6 for the design rationale and §3 for what we
// deliberately don't model.
export type BlockKind = "block" | "actor" | "constraint" | "system";
export type PropertyType =
| { kind: "string" }
| { kind: "number" }
| { kind: "boolean" }
| { kind: "enum"; values: string[] };
export type Multiplicity = "0..1" | "1" | "0..*" | "1..*";
export interface Property {
id: string;
name: string;
type: PropertyType;
multiplicity: Multiplicity;
description?: string;
}
export interface Block {
id: string;
label: string;
kind: BlockKind;
stereotypes: string[];
properties: Property[];
description?: string;
}
export type AssociationKind =
| "association"
| "composition"
| "aggregation"
| "generalization"
| "constraintApplies";
export interface Association {
id: string;
fromBlockId: string;
toBlockId: string;
label: string;
kind: AssociationKind;
multiplicity?: { from: Multiplicity; to: Multiplicity };
}
export interface Constraint {
id: string;
label: string;
expression: string;
appliesTo: string[];
}
export type RequirementRelation =
| { kind: "satisfy"; blockId: string }
| { kind: "derive"; fromReqId: string }
| { kind: "verify"; experimentId: string };
export interface Requirement {
id: string;
tag: string;
text: string;
relations: RequirementRelation[];
}
export interface SysMLModel {
systemOfInterestId?: string;
blocks: Block[];
associations: Association[];
constraints: Constraint[];
requirements: Requirement[];
}

View File

@@ -0,0 +1,324 @@
// Pure SysML model validator.
//
// 13 rules, grouped:
// - Structural (S1S5) — referential integrity. Errors.
// - Semantic (M1M5) — SysML metamodel rules. Mostly errors.
// - Traceability (T1T2) — soft warnings on coverage.
//
// All rules are described in docs/sysml-modeling.md §7.
// Pure function; safe to call repeatedly. Output is element-anchored so the
// UI can dot/highlight the offending block, association, etc.
import type {
SysMLModel,
Block,
Association,
Constraint,
Requirement,
} from "./model";
export type Severity = "error" | "warning" | "soft";
export type IssueAnchor =
| { kind: "block"; id: string }
| { kind: "association"; id: string }
| { kind: "constraint"; id: string }
| { kind: "requirement"; id: string }
| { kind: "property"; blockId: string; propertyId: string }
| { kind: "model" };
export interface ValidationIssue {
/** Stable rule code: S1, M2, T1, etc. */
code: string;
severity: Severity;
message: string;
anchor: IssueAnchor;
}
export function validate(model: SysMLModel): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const blockIds = new Set(model.blocks.map(b => b.id));
const reqIds = new Set(model.requirements.map(r => r.id));
// Lookups
const blockById = new Map<string, Block>(model.blocks.map(b => [b.id, b]));
// ─── Structural ────────────────────────────────────────────────────────
// S1 — every association.fromBlockId / toBlockId resolves to a block
for (const a of model.associations) {
if (!blockIds.has(a.fromBlockId)) {
issues.push(s1IssueFor(a, "from", a.fromBlockId));
}
if (!blockIds.has(a.toBlockId)) {
issues.push(s1IssueFor(a, "to", a.toBlockId));
}
}
// S2 — every constraint.appliesTo entry resolves to a block
for (const c of model.constraints) {
for (const target of c.appliesTo) {
if (!blockIds.has(target)) {
issues.push({
code: "S2",
severity: "error",
message: `Constraint "${c.label}" applies to unknown block "${target}".`,
anchor: { kind: "constraint", id: c.id },
});
}
}
}
// S3 — every requirement satisfy.blockId resolves to a block
// S4 — every requirement derive.fromReqId resolves to a requirement
for (const r of model.requirements) {
for (const rel of r.relations) {
if (rel.kind === "satisfy" && !blockIds.has(rel.blockId)) {
issues.push({
code: "S3",
severity: "error",
message: `Requirement ${r.tag} is satisfied by unknown block "${rel.blockId}".`,
anchor: { kind: "requirement", id: r.id },
});
}
if (rel.kind === "derive" && !reqIds.has(rel.fromReqId)) {
issues.push({
code: "S4",
severity: "error",
message: `Requirement ${r.tag} derives from unknown requirement "${rel.fromReqId}".`,
anchor: { kind: "requirement", id: r.id },
});
}
}
}
// S5 — block ids unique; requirement tags unique; association ids unique
uniquenessIssues(model.blocks, b => b.id, "S5", "block id", b => ({ kind: "block", id: b.id })).forEach(i => issues.push(i));
uniquenessIssues(model.associations, a => a.id, "S5", "association id", a => ({ kind: "association", id: a.id })).forEach(i => issues.push(i));
uniquenessIssues(model.constraints, c => c.id, "S5", "constraint id", c => ({ kind: "constraint", id: c.id })).forEach(i => issues.push(i));
uniquenessIssues(model.requirements, r => r.tag, "S5", "requirement tag", r => ({ kind: "requirement", id: r.id })).forEach(i => issues.push(i));
// ─── Semantic (SysML metamodel) ────────────────────────────────────────
// M1 — generalization is acyclic
cycles(
model.blocks,
block => model.associations.filter(a => a.kind === "generalization" && a.fromBlockId === block.id).map(a => a.toBlockId),
blockById
).forEach(cycle =>
issues.push({
code: "M1",
severity: "error",
message: `Cyclic generalization: ${cycle.join(" → ")}${cycle[0]}`,
anchor: { kind: "block", id: cycle[0]! },
})
);
// M2 — composition is acyclic
cycles(
model.blocks,
block => model.associations.filter(a => a.kind === "composition" && a.fromBlockId === block.id).map(a => a.toBlockId),
blockById
).forEach(cycle =>
issues.push({
code: "M2",
severity: "error",
message: `Cyclic composition: ${cycle.join(" → ")}${cycle[0]}`,
anchor: { kind: "block", id: cycle[0]! },
})
);
// M3 — exactly zero or one block has kind "system"
const systems = model.blocks.filter(b => b.kind === "system");
if (systems.length > 1) {
for (const s of systems) {
issues.push({
code: "M3",
severity: "error",
message: `Multiple System-of-Interest blocks: ${systems.map(x => x.label).join(", ")}.`,
anchor: { kind: "block", id: s.id },
});
}
}
// M4 — constraint blocks may not participate in composition
for (const a of model.associations) {
if (a.kind !== "composition") continue;
const from = blockById.get(a.fromBlockId);
const to = blockById.get(a.toBlockId);
if ((from && from.kind === "constraint") || (to && to.kind === "constraint")) {
issues.push({
code: "M4",
severity: "error",
message: `Composition involves a constraint block (${from?.kind === "constraint" ? from.label : to?.label}).`,
anchor: { kind: "association", id: a.id },
});
}
}
// M5 — property names unique within a block
for (const b of model.blocks) {
const seen = new Map<string, number>();
for (const p of b.properties) {
seen.set(p.name, (seen.get(p.name) ?? 0) + 1);
}
for (const [name, count] of seen) {
if (count > 1) {
issues.push({
code: "M5",
severity: "error",
message: `Block "${b.label}" has duplicate property "${name}" (×${count}).`,
anchor: { kind: "block", id: b.id },
});
}
}
}
// ─── Traceability ──────────────────────────────────────────────────────
// T1 — every requirement has at least one satisfy relation
for (const r of model.requirements) {
const hasSatisfier = r.relations.some(rel => rel.kind === "satisfy");
if (!hasSatisfier) {
issues.push({
code: "T1",
severity: "warning",
message: `Requirement ${r.tag} has no satisfying block.`,
anchor: { kind: "requirement", id: r.id },
});
}
}
// T2 — every block is referenced by ≥1 association OR satisfies ≥1 requirement
// (constraint blocks are exempt — they don't need to be referenced via association)
const referencedBlockIds = new Set<string>();
for (const a of model.associations) {
referencedBlockIds.add(a.fromBlockId);
referencedBlockIds.add(a.toBlockId);
}
for (const r of model.requirements) {
for (const rel of r.relations) {
if (rel.kind === "satisfy") referencedBlockIds.add(rel.blockId);
}
}
for (const c of model.constraints) {
for (const target of c.appliesTo) referencedBlockIds.add(target);
}
for (const b of model.blocks) {
if (b.kind === "constraint") continue;
if (!referencedBlockIds.has(b.id)) {
issues.push({
code: "T2",
severity: "soft",
message: `Block "${b.label}" is not referenced by any association, constraint, or requirement.`,
anchor: { kind: "block", id: b.id },
});
}
}
return issues;
}
// ─── Helpers ─────────────────────────────────────────────────────────────
function s1IssueFor(a: Association, side: "from" | "to", missingId: string): ValidationIssue {
return {
code: "S1",
severity: "error",
message: `Association "${a.label || a.id}" ${side} unknown block "${missingId}".`,
anchor: { kind: "association", id: a.id },
};
}
function uniquenessIssues<T>(
items: T[],
keyOf: (item: T) => string,
code: string,
what: string,
anchorOf: (item: T) => IssueAnchor
): ValidationIssue[] {
const counts = new Map<string, number>();
for (const it of items) counts.set(keyOf(it), (counts.get(keyOf(it)) ?? 0) + 1);
const issues: ValidationIssue[] = [];
for (const it of items) {
const k = keyOf(it);
if ((counts.get(k) ?? 0) > 1) {
issues.push({
code,
severity: "error",
message: `Duplicate ${what} "${k}".`,
anchor: anchorOf(it),
});
}
}
return issues;
}
/**
* Find all simple cycles in a directed graph, returning each cycle as an
* array of node ids. Uses DFS with a recursion stack (Tarjan-style).
* Each cycle is reported once, starting from its lexicographically smallest
* node (so duplicates from different start positions collapse).
*/
function cycles<T extends { id: string }>(
nodes: T[],
outgoing: (node: T) => string[],
byId: Map<string, T>
): string[][] {
const result: string[][] = [];
const seenCycles = new Set<string>();
function dfs(currentId: string, path: string[], onPath: Set<string>) {
const node = byId.get(currentId);
if (!node) return;
for (const nextId of outgoing(node)) {
if (onPath.has(nextId)) {
// Found a cycle from nextId back to itself through the path
const startIdx = path.indexOf(nextId);
if (startIdx >= 0) {
const cycle = path.slice(startIdx);
const key = canonicalCycleKey(cycle);
if (!seenCycles.has(key)) {
seenCycles.add(key);
result.push(cycle);
}
}
continue;
}
onPath.add(nextId);
path.push(nextId);
dfs(nextId, path, onPath);
path.pop();
onPath.delete(nextId);
}
}
for (const n of nodes) {
dfs(n.id, [n.id], new Set([n.id]));
}
return result;
}
function canonicalCycleKey(cycle: string[]): string {
// Rotate so the smallest id is first; then join.
let minIdx = 0;
for (let i = 1; i < cycle.length; i++) {
if (cycle[i]! < cycle[minIdx]!) minIdx = i;
}
return [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)].join("→");
}
// ─── Convenience accessors used by the UI ────────────────────────────────
export function severityRank(s: Severity): number {
return s === "error" ? 3 : s === "warning" ? 2 : 1;
}
export function maxSeverity(issues: ValidationIssue[]): Severity | null {
let best: Severity | null = null;
for (const i of issues) {
if (best === null || severityRank(i.severity) > severityRank(best)) best = i.severity;
}
return best;
}