Files
learnharness/src/core/phases.ts
T
amosblomqvist 2f39766528 free-recall explain tool: the quizzing-mechanic switch (owner 2026-09-27)
Sycophancy mitigation as a structural commitment: the model writes its
full correctAnswer (plus explanation, in that order) BEFORE the learner
sees the question; the commitment rides the persisted call args, is
revealed on submit, and grades his free text in the model's next reply —
the client never grades free recall.

- tools.explain setting (segmented 'Quizzing — Multiple choice | Free
  recall' in Agent settings' Tools group): free arms explain and drops
  quiz for BOTH agents (prober variant carries no nodeId, same pattern
  as the probe quiz); mechanics keys never pass filterEnabledTools —
  quiz stays unwritable (the 2026-09-19 wedge ruling stands)
- doctrine lives pi-natively in the tool descriptions (owner ruling —
  no conditional system-prompt chunks): explain carries the commitment
  rule; the MC option-building craft moved out of the prompt into the
  quiz description; shared/20-question-tools.md slims to the
  quiz-agnostic core
- display seams: parseExplainInput (LaTeX-debris restoration included),
  dock text-mode with an 'I don't know' row (kbd row 0), AnsweredCard
  reveal (The answer / Why, no verdict chips), [Explain answer] format
- phases/plan seams recognize both quizzing tools' nodeId tags; explain
  answers deliberately feed no ledger evidence (no client grading to
  key on — a future ruling can parse the model's grade)

1049/1049 tests, typecheck/lint/build green.
2026-09-27 08:55:13 +03:00

157 lines
6.1 KiB
TypeScript

/**
* Phase derivation (docs/probe-teach-split.md §4) — EXPLICIT EVENTS, never
* occupancy. The probe→teach agent split (2026-09-11) gave the first
* boundary a transcript event; the second is INFERRED (owner ruling
* 2026-09-26, the verdict death):
*
* probe — until the prober's `probe_done` call CONCLUDES (a clean
* toolResult for it; the assessment rides the call's arguments)
* plan — probe concluded, until teaching actually BEGINS
* teach — the first quiz tool call tagged with a `nodeId` (a question
* ABOUT a plan node — probe quizzes carry no nodeId in their
* schema, so nothing false-triggers). The CALL is the seam, not
* its answer: the block that poses the first node question is
* where teaching starts.
*
* There is no verdict gate anymore: presenting the plan ends the teacher's
* turn, and the learner's next message is a natural reply (a change
* request, or the go-ahead). The live indicator LAGS by design (owner
* accepted): "teach" shows only once the first node's question is posed.
* A teacher that never tags a quiz can no longer exist — nodeId is
* schema-required at the teach quiz boundary (owner ruling 2026-09-26),
* so the seam signal is structural, not prompt-mandated.
*
* The former derived-occupancy seam (phase-of-tree via `currentNodeId`)
* is GONE, as is the verdict-marker fold: no backwards compatibility
* (legacy sessions are wiped data).
*
* TWO FOLDS over the same events (Wave D): `phaseAt3`/`phaseTimeline` fold
* VIEW messages (the client's dividers and dock — part shapes, pure and
* structural); `phaseAt3Lh` folds the NATIVE record (server summaries,
* tests) — probe→plan advances at the probe_done RESULT message, plan→teach
* at the assistant message carrying the first nodeId-tagged quiz CALL.
* Both are strictly sequential; the two derivations agree on every settled
* record (a call's part projects into the same view boundary).
*
* Pure: no imports, no runtime deps — safe on server, client, and tests.
*/
/** The three phases of a session, as the picker / header / dividers see them. */
export type Phase3 = "probe" | "plan" | "teach";
/**
* Minimal structural slice of a UI message for the phase scan: parts with
* a state, an input (a tool call's parsed arguments), and (for concluded
* calls) an output.
*/
export interface PhaseMessage {
parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; output?: unknown }>;
}
/** A quiz call's part input carries `nodeId` iff it is tagged (teach-only —
* the probe quiz schema has no nodeId field). */
function taggedNodeId(input: unknown): boolean {
return (
typeof input === "object" &&
input !== null &&
typeof (input as { nodeId?: unknown }).nodeId === "string" &&
(input as { nodeId: string }).nodeId.length > 0
);
}
/** The phase at `upToIndex` (inclusive; default: the last message). */
export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number): Phase3 {
let phase: Phase3 = "probe";
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
for (let i = 0; i <= last; i++) {
phase = advance(phase, messages[i]?.parts);
}
return phase;
}
/** One message's advance of the phase (pure fold step). */
function advance(
phase: Phase3,
parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; output?: unknown }> | undefined,
): Phase3 {
if (!parts) return phase;
for (const part of parts) {
// STRICTLY SEQUENTIAL: probe → plan only via a concluded probe_done,
// plan → teach only via the first nodeId-tagged quiz CALL (the call,
// not its answer — an errored-and-retried call still proves teaching
// began, and the retry lands in the same group anyway).
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
phase = "plan";
}
// plan → teach via the first nodeId-tagged quizzing CALL — quiz OR
// explain, whichever mechanic the settings armed (both carry nodeId).
if (
phase === "plan" &&
(part.type === "tool-quiz" || part.type === "tool-explain") &&
taggedNodeId(part.input)
) {
phase = "teach";
}
}
return phase;
}
/**
* The phase at EVERY message index, in one forward fold — the transcript
* dividers render one per boundary, so phaseAt3(m, i) per index would rescan
* 0..i each time (quadratic); this is the same derivation, linear.
* out[i] === phaseAt3(messages, i) for every i.
*/
export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
const out: Phase3[] = [];
let phase: Phase3 = "probe";
for (let i = 0; i < messages.length; i++) {
phase = advance(phase, messages[i]?.parts);
out.push(phase);
}
return out;
}
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
import type { LhMessage } from "./native-messages.ts";
/** The phase at `upToIndex` (inclusive; default: the last message) of a
* NATIVE record. probe→plan advances at the probe_done call's clean
* toolResult; plan→teach at the assistant message carrying the first
* nodeId-tagged quiz CALL (mirrors the view fold — same seam, native
* shape). */
export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 {
let phase: Phase3 = "probe";
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
for (let i = 0; i <= last; i++) {
const m = messages[i]!;
const role = (m as { role?: string }).role;
if (role === "toolResult") {
const result = m as { toolName?: string; isError?: boolean };
if (
phase === "probe" &&
result.toolName === "probe_done" &&
!result.isError
) {
phase = "plan";
}
} else if (role === "assistant") {
const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? [];
if (phase === "plan") {
for (const block of content) {
if (
block.type === "toolCall" &&
(block.name === "quiz" || block.name === "explain") &&
taggedNodeId(block.arguments)
) {
phase = "teach";
break;
}
}
}
}
}
return phase;
}