quiz answers carry a confidence slider — raw probability, snapped 0/30/50/70/90/100
Owner design round 2026-10-01: every multiple-choice quiz answer now carries the learner's stated probability that his selected answer is correct — calibration data for the prober and teacher, the flow itself unchanged. Definition (owner ruling, after weighing chance-normalization): RAW probability. Sub-chance reports are diagnostic gold, not dead space — 'picked an option, believes it wrong' is a real state. The agents get the sophistication: new shared/25-confidence.md composes for both agents (chance level, sub-chance = distrusts the pick, correct+low = fragile, wrong+high = committed misconception, IDK = genuine gap). - question-core: AnswerRequest/QuestionAnswerData.confidence (quiz only, optional — old records valid), buildAnswer clamps and drops it on dontKnow, formatAnswerMessage states the line, CONFIDENCE_STOPS + DEFAULT_CONFIDENCE_INDEX exported. - question-dock: snapped slider below 'I don't know', above 'Your thinking' — native range over the stop INDEX so ←/→ step the uneven grid cleanly, Tab chain rows → slider → thinking, Enter submits from the slider too. Rests at 50 and always sends (owner: 50 must mean a real coin flip, never 'untouched'). 'I don't know' GRAYS it out — layout holds, no value sent. Deliberately untouched: explain free-recall card (quiz-only scope), answered-card rendering of confidence, ask flow.
This commit is contained in:
@@ -9,6 +9,8 @@ import { cn } from "@/lib/utils";
|
|||||||
import {
|
import {
|
||||||
buildAnswer,
|
buildAnswer,
|
||||||
coerceCorrectAnswer,
|
coerceCorrectAnswer,
|
||||||
|
CONFIDENCE_STOPS,
|
||||||
|
DEFAULT_CONFIDENCE_INDEX,
|
||||||
emptyDockSelection,
|
emptyDockSelection,
|
||||||
normalizeOptions,
|
normalizeOptions,
|
||||||
resolveCorrect,
|
resolveCorrect,
|
||||||
@@ -264,6 +266,12 @@ function QuestionDockForm({
|
|||||||
const [thinking, setThinking] = useState("");
|
const [thinking, setThinking] = useState("");
|
||||||
const [other, setOther] = useState("");
|
const [other, setOther] = useState("");
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
|
// Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS,
|
||||||
|
// resting at 50 — a real coin flip by definition, untouched or
|
||||||
|
// deliberate (the same number, the same meaning). The dock unmounts
|
||||||
|
// between questions, so each question starts fresh at 50.
|
||||||
|
const [confIdx, setConfIdx] = useState<number>(DEFAULT_CONFIDENCE_INDEX);
|
||||||
|
const confidenceRef = useRef<HTMLInputElement>(null);
|
||||||
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
|
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
|
||||||
|
|
||||||
// The PRIMARY dictation input (owner, STT round 1): while a question
|
// The PRIMARY dictation input (owner, STT round 1): while a question
|
||||||
@@ -297,6 +305,9 @@ function QuestionDockForm({
|
|||||||
other: sel.otherSelected ? other : undefined,
|
other: sel.otherSelected ? other : undefined,
|
||||||
text: qd.mode === "text" ? text : undefined,
|
text: qd.mode === "text" ? text : undefined,
|
||||||
note: qd.mode !== "text" ? thinking.trim() || undefined : undefined,
|
note: qd.mode !== "text" ? thinking.trim() || undefined : undefined,
|
||||||
|
// Always sent on quiz answers (owner: 50 must mean 50, never
|
||||||
|
// "untouched"); buildAnswer drops it on "I don't know".
|
||||||
|
confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined,
|
||||||
};
|
};
|
||||||
onSubmit(buildAnswer(qd, req));
|
onSubmit(buildAnswer(qd, req));
|
||||||
};
|
};
|
||||||
@@ -368,14 +379,19 @@ function QuestionDockForm({
|
|||||||
// Alt/Cmd/Ctrl combos are never ours (Alt+arrows cycle sessions
|
// Alt/Cmd/Ctrl combos are never ours (Alt+arrows cycle sessions
|
||||||
// globally — they must not ALSO walk the question rows).
|
// globally — they must not ALSO walk the question rows).
|
||||||
const modded = e.altKey || e.metaKey || e.ctrlKey;
|
const modded = e.altKey || e.metaKey || e.ctrlKey;
|
||||||
// Plain Tab toggles focus: rows → thinking, anything else → the
|
// Plain Tab walks the dock's focus chain forward: rows →
|
||||||
// walked (or first) row. Shift+Tab falls through (the global
|
// confidence slider (quiz, when enabled) → thinking. Shift+Tab
|
||||||
// thinking-level cycle owns it), so the shift check matters.
|
// falls through (the global thinking-level cycle owns it), so the
|
||||||
|
// shift check matters.
|
||||||
if (e.key === "Tab" && !e.shiftKey && !modded) {
|
if (e.key === "Tab" && !e.shiftKey && !modded) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (t?.closest?.("[data-kbdrow]")) thinkingRef.current?.focus();
|
if (t?.closest?.("[data-confidence]")) thinkingRef.current?.focus();
|
||||||
else {
|
else if (t?.closest?.("[data-kbdrow]")) {
|
||||||
|
const slider = confidenceRef.current;
|
||||||
|
if (slider && !slider.disabled) slider.focus();
|
||||||
|
else thinkingRef.current?.focus();
|
||||||
|
} else {
|
||||||
const idx = kbdIdx ?? 0;
|
const idx = kbdIdx ?? 0;
|
||||||
setKbdIdx(idx);
|
setKbdIdx(idx);
|
||||||
focusRow(idx);
|
focusRow(idx);
|
||||||
@@ -573,6 +589,59 @@ function QuestionDockForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Confidence (owner, 2026-10-01): a snapped slider below the options
|
||||||
|
and above "Your thinking" — the learner's stated probability
|
||||||
|
that his selected answer is correct. ←/→ move it (native range
|
||||||
|
semantics over the stop INDEX, so the uneven grid steps
|
||||||
|
cleanly). "I don't know" GRAYS it out — the layout holds, no
|
||||||
|
value is sent, the IDK signal stands alone. */}
|
||||||
|
{kind === "quiz" && (
|
||||||
|
<div className="border-t border-border/60 px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-xs font-medium uppercase tracking-wide",
|
||||||
|
sel.dontKnow ? "text-muted-foreground/50" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Confidence
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={confidenceRef}
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={CONFIDENCE_STOPS.length - 1}
|
||||||
|
step={1}
|
||||||
|
value={confIdx}
|
||||||
|
disabled={sel.dontKnow}
|
||||||
|
aria-label="Confidence — the probability your selected answer is correct"
|
||||||
|
title="The probability your selected answer is correct — 50% is a coin flip"
|
||||||
|
data-testid="confidence-slider"
|
||||||
|
data-confidence=""
|
||||||
|
onChange={(e) => setConfIdx(Number(e.target.value))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter submits from here too (dock-wide intuition);
|
||||||
|
// arrows stay native — ←/→ step the stop index.
|
||||||
|
if (e.key === "Enter" && canSubmit) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-1.5 min-w-0 flex-1 accent-primary disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
data-testid="confidence-value"
|
||||||
|
className={cn(
|
||||||
|
"w-9 shrink-0 text-right font-mono text-sm tabular-nums",
|
||||||
|
sel.dontKnow && "text-muted-foreground/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sel.dontKnow ? "—" : `${CONFIDENCE_STOPS[confIdx]}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* The learner's half of the dock — a composer TWIN: a labeled, bordered
|
{/* The learner's half of the dock — a composer TWIN: a labeled, bordered
|
||||||
field with Submit docked inside, exactly like the chat composer's
|
field with Submit docked inside, exactly like the chat composer's
|
||||||
send. Select modes get "Your thinking" (Enter makes a newline — it
|
send. Select modes get "Your thinking" (Enter makes a newline — it
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
## Confidence — reading the learner's self-assessment
|
||||||
|
|
||||||
|
Every quiz answer arrives with a `Confidence: N%` line: the learner's stated probability that his selected answer is correct, set on a snapped slider (0, 30, 50, 70, 90, 100). This is calibration data, not decoration. Read it against the verdict:
|
||||||
|
|
||||||
|
- 50 is a genuine coin flip. 90–100 claims certainty; 0–30 claims real doubt.
|
||||||
|
- For an n-option question, chance is 1/n. Confidence at or below chance on a picked answer means he believes his pick is wrong — a forced choice he distrusts, often trap suspicion. Treat it as honesty, not error.
|
||||||
|
- Correct + high confidence: solid knowledge — move on.
|
||||||
|
- Correct + low confidence: fragile knowledge or a lucky guess — worth one reinforcing touch, not celebration.
|
||||||
|
- Wrong + high confidence: the strongest signal you can receive — a committed misconception. Name the crack, probe it gently, and re-teach around it.
|
||||||
|
- Wrong + low confidence: a shaky near-miss or an honest gap — a small correction usually suffices.
|
||||||
|
- "I don't know" carries no confidence line: he declined to guess. A genuine gap, never a wrong answer.
|
||||||
@@ -350,6 +350,7 @@ export interface QuestionAnswerData {
|
|||||||
other?: string; // ask: "Other" free text
|
other?: string; // ask: "Other" free text
|
||||||
text?: string; // ask/explain: text-mode answer
|
text?: string; // ask/explain: text-mode answer
|
||||||
note?: string;
|
note?: string;
|
||||||
|
confidence?: number; // quiz only: the learner's stated probability (0-100, raw) that his selected answer is correct — never sent on "I don't know" (owner, 2026-10-01)
|
||||||
correct?: boolean; // quiz only
|
correct?: boolean; // quiz only
|
||||||
correctIndices?: number[]; // quiz only (rendering)
|
correctIndices?: number[]; // quiz only (rendering)
|
||||||
committed?: string; // explain only (rendering): the model's committed answer
|
committed?: string; // explain only (rendering): the model's committed answer
|
||||||
@@ -375,8 +376,17 @@ export interface AnswerRequest {
|
|||||||
other?: string; // ask "Other"
|
other?: string; // ask "Other"
|
||||||
text?: string; // ask text-mode
|
text?: string; // ask text-mode
|
||||||
note?: string;
|
note?: string;
|
||||||
|
confidence?: number; // quiz only: 0-100 raw probability — 50 is a real coin flip, always sent (owner, 2026-10-01)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The confidence slider's snap stops (owner, 2026-10-01): raw probability
|
||||||
|
* that the selected answer is correct, uneven on purpose — resolution
|
||||||
|
* concentrates at the top where the calibration signal lives (90 vs 100
|
||||||
|
* separates knows from mastered). 50 is a genuine coin flip and the
|
||||||
|
* resting default — untouched and deliberate 50 are the same number. */
|
||||||
|
export const CONFIDENCE_STOPS: readonly number[] = [0, 30, 50, 70, 90, 100];
|
||||||
|
export const DEFAULT_CONFIDENCE_INDEX = 2;
|
||||||
|
|
||||||
// ─── dock selection (the client state machine, pure for tests) ─────────────
|
// ─── dock selection (the client state machine, pure for tests) ─────────────
|
||||||
|
|
||||||
/** The question dock's selection state (0-based indices in display order). */
|
/** The question dock's selection state (0-based indices in display order). */
|
||||||
@@ -571,7 +581,6 @@ export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswe
|
|||||||
note: req.note?.trim() || undefined,
|
note: req.note?.trim() || undefined,
|
||||||
answeredAt: new Date().toISOString(),
|
answeredAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// resolve selected option values → OptionAnswer (in display order)
|
// resolve selected option values → OptionAnswer (in display order)
|
||||||
if (req.values && req.values.length > 0) {
|
if (req.values && req.values.length > 0) {
|
||||||
const byValue = new Map(qd.options.map((o, i) => [o.value, { ...o, index: i + 1 }]));
|
const byValue = new Map(qd.options.map((o, i) => [o.value, { ...o, index: i + 1 }]));
|
||||||
@@ -589,6 +598,12 @@ export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswe
|
|||||||
base.correctIndices = qd.correctIndices ?? [];
|
base.correctIndices = qd.correctIndices ?? [];
|
||||||
base.explanation = qd.explanation;
|
base.explanation = qd.explanation;
|
||||||
base.correct = base.dontKnow ? false : isCorrect(base.answers.map((a) => a.index), qd.correctIndices ?? []);
|
base.correct = base.dontKnow ? false : isCorrect(base.answers.map((a) => a.index), qd.correctIndices ?? []);
|
||||||
|
// The confidence slider rides on real quiz answers only — never on
|
||||||
|
// "I don't know" (no answer, no probability). Clamped defensively;
|
||||||
|
// the dock snaps to the grid, the record accepts any integer.
|
||||||
|
if (!base.dontKnow && req.confidence !== undefined) {
|
||||||
|
base.confidence = Math.max(0, Math.min(100, Math.round(req.confidence)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (qd.kind === "explain") {
|
if (qd.kind === "explain") {
|
||||||
// No client grading — the model grades in its reply, against the
|
// No client grading — the model grades in its reply, against the
|
||||||
@@ -620,6 +635,9 @@ export function formatAnswerMessage(ad: QuestionAnswerData): string {
|
|||||||
const selectedStr = ad.answers.map((a) => `${a.index}. ${a.label}`).join(", ");
|
const selectedStr = ad.answers.map((a) => `${a.index}. ${a.label}`).join(", ");
|
||||||
lines.push(`Selected: ${selectedStr || "(none)"}`);
|
lines.push(`Selected: ${selectedStr || "(none)"}`);
|
||||||
lines.push(`Result: ${verdict}`);
|
lines.push(`Result: ${verdict}`);
|
||||||
|
if (ad.confidence !== undefined) {
|
||||||
|
lines.push(`Confidence: ${ad.confidence}% — the learner's stated probability that his selected answer is correct.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lines.push(`Correct: ${correctStr}`);
|
lines.push(`Correct: ${correctStr}`);
|
||||||
} else if (ad.kind === "explain") {
|
} else if (ad.kind === "explain") {
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ describe("the source-derived segment list", () => {
|
|||||||
"system-prompt.md",
|
"system-prompt.md",
|
||||||
"prober/10-probe-doctrine.md",
|
"prober/10-probe-doctrine.md",
|
||||||
"shared/20-question-tools.md",
|
"shared/20-question-tools.md",
|
||||||
|
"shared/25-confidence.md",
|
||||||
"shared/30-learner-files.md",
|
"shared/30-learner-files.md",
|
||||||
"shared/40-formatting.md",
|
"shared/40-formatting.md",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
askOptionProblems,
|
askOptionProblems,
|
||||||
buildAnswer,
|
buildAnswer,
|
||||||
coerceCorrectAnswer,
|
coerceCorrectAnswer,
|
||||||
|
CONFIDENCE_STOPS,
|
||||||
|
DEFAULT_CONFIDENCE_INDEX,
|
||||||
findPendingQuestion,
|
findPendingQuestion,
|
||||||
formatAnswerMessage,
|
formatAnswerMessage,
|
||||||
isCorrect,
|
isCorrect,
|
||||||
@@ -337,6 +339,13 @@ function quizData(overrides?: Partial<QuestionData>): QuestionData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("confidence stops", () => {
|
||||||
|
it("the owner-decided grid (2026-10-01): resolution concentrates at the top", () => {
|
||||||
|
assert.deepEqual(CONFIDENCE_STOPS, [0, 30, 50, 70, 90, 100]);
|
||||||
|
assert.equal(CONFIDENCE_STOPS[DEFAULT_CONFIDENCE_INDEX], 50, "rests at a real coin flip");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildAnswer", () => {
|
describe("buildAnswer", () => {
|
||||||
it("grades a correct selection", () => {
|
it("grades a correct selection", () => {
|
||||||
const ad = buildAnswer(quizData(), { values: ["mercury"] });
|
const ad = buildAnswer(quizData(), { values: ["mercury"] });
|
||||||
@@ -368,6 +377,22 @@ describe("buildAnswer", () => {
|
|||||||
assert.deepEqual(ad.answers.map((a) => a.value), ["mercury"]);
|
assert.deepEqual(ad.answers.map((a) => a.value), ["mercury"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Confidence (owner, 2026-10-01): the slider's raw probability rides on
|
||||||
|
// real quiz answers only — never on "I don't know" (no answer, no
|
||||||
|
// probability), never on ask/explain. Clamped defensively.
|
||||||
|
it("carries confidence on quiz answers and clamps out-of-range values", () => {
|
||||||
|
assert.equal(buildAnswer(quizData(), { values: ["mercury"], confidence: 90 }).confidence, 90);
|
||||||
|
assert.equal(buildAnswer(quizData(), { values: ["mercury"], confidence: 150 }).confidence, 100);
|
||||||
|
assert.equal(buildAnswer(quizData(), { values: ["mercury"], confidence: -5 }).confidence, 0);
|
||||||
|
assert.equal(buildAnswer(quizData(), { values: ["mercury"] }).confidence, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops confidence on dontKnow and on non-quiz kinds", () => {
|
||||||
|
assert.equal(buildAnswer(quizData(), { dontKnow: true, confidence: 70 }).confidence, undefined);
|
||||||
|
const ask = quizData({ kind: "ask", correctIndices: undefined, explanation: undefined });
|
||||||
|
assert.equal(buildAnswer(ask, { other: "x", confidence: 70 }).confidence, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
it("multi-select grades as exact-set", () => {
|
it("multi-select grades as exact-set", () => {
|
||||||
const multi = quizData({
|
const multi = quizData({
|
||||||
mode: "multi-select",
|
mode: "multi-select",
|
||||||
@@ -412,6 +437,13 @@ describe("formatAnswerMessage", () => {
|
|||||||
assert.match(formatAnswerMessage(ad), /Note: guessed/);
|
assert.match(formatAnswerMessage(ad), /Note: guessed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("states the confidence line on answered quizzes, absent on dontKnow", () => {
|
||||||
|
const ad = buildAnswer(quizData(), { values: ["mercury"], confidence: 70 });
|
||||||
|
assert.match(formatAnswerMessage(ad), /Confidence: 70% — the learner's stated probability that his selected answer is correct\./);
|
||||||
|
const idk = buildAnswer(quizData(), { dontKnow: true, confidence: 70 });
|
||||||
|
assert.ok(!formatAnswerMessage(idk).includes("Confidence:"));
|
||||||
|
});
|
||||||
|
|
||||||
it("formats ask answers", () => {
|
it("formats ask answers", () => {
|
||||||
const ask = quizData({ kind: "ask", correctIndices: undefined, explanation: undefined });
|
const ask = quizData({ kind: "ask", correctIndices: undefined, explanation: undefined });
|
||||||
const ad = buildAnswer(ask, { other: "whatever" });
|
const ad = buildAnswer(ask, { other: "whatever" });
|
||||||
|
|||||||
Reference in New Issue
Block a user