Review-only snapshot for BUG-1115 through BUG-1117; not merge-ready. New opening and append-turn PostgreSQL permission failures remain blocked. Persisted joint replay has zero completed questions; segment ordering remains off by default. The existing offline replay JSON is retained stale and unchanged after a denied overwrite, including its CRLF line endings. Browser/provider validation and final serial gates remain pending. No deployment, role permission changes, or staging/main push. Co-Authored-By: Claude Code <noreply@anthropic.com>
272 lines
14 KiB
TypeScript
272 lines
14 KiB
TypeScript
import { candidateSetId } from "./candidate-set-id.ts";
|
|
import { readCandidatePosition, parseWindowIntervals, type CandidatePosition } from "./candidate-window.ts";
|
|
import { INFERENCE_ALGORITHM_VERSION, type InferenceState } from "./types.ts";
|
|
import { buildSegmentSummary, parseAscendantSignature, parseSegmentSummary, TARGET_CHARTS, type SegmentMinute } from "./segment-summary.ts";
|
|
|
|
export type InferenceTransitionSnapshot = Readonly<{
|
|
id?: string;
|
|
resultId: string;
|
|
revision: number;
|
|
probeId: string | null;
|
|
reason: string;
|
|
idempotent?: boolean;
|
|
decisionStateFingerprint: string;
|
|
inferenceState: InferenceState;
|
|
posteriorBefore: Readonly<Record<string, number>>;
|
|
posteriorAfter: Readonly<Record<string, number>>;
|
|
scoreDeltas: Readonly<Record<string, number>>;
|
|
entropyBefore?: number | null;
|
|
entropyAfter?: number | null;
|
|
eliminatedCandidateIds?: readonly string[];
|
|
roundKind?: "informative" | "low_information" | "tie_break" | null;
|
|
}>;
|
|
|
|
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
const PHASES = new Set([
|
|
"intake", "event_collection", "candidate_generation", "candidate_scoring",
|
|
"discrimination", "holdout_validation", "completed", "stopped",
|
|
]);
|
|
const RESULT_STATUSES = new Set([
|
|
"converged", "credible_range", "completed_with_range", "insufficient_evidence",
|
|
"max_rounds_reached", "validation_failed", "discriminating",
|
|
]);
|
|
const CANDIDATE_STATUSES = new Set(["active", "eliminated", "winner", "equivalent"]);
|
|
const EVENT_PRECISIONS = new Set(["day", "month", "year", "unknown"]);
|
|
const EVENT_USAGES = new Set(["training", "holdout", "unused"]);
|
|
const ANSWER_CLASSES = new Set(["yes", "weak_yes", "no", "unsure"]);
|
|
const PROBE_CHOICE_KINDS = new Set(["existence", "varga_style", "event_quality"]);
|
|
const ANSWER_SOURCES = new Set(["choice", "evidence", "declined"]);
|
|
const ROUND_KINDS = new Set(["informative", "low_information", "tie_break"]);
|
|
const SCORE_DIRECTIONS = new Set(["support", "weak_support", "neutral", "weak_conflict", "conflict"]);
|
|
|
|
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function isClockRange(value: unknown, dated = false): value is readonly [string, string] {
|
|
return Array.isArray(value)
|
|
&& value.length === 2
|
|
&& typeof value[0] === "string"
|
|
&& typeof value[1] === "string"
|
|
&& CLOCK.test(value[0])
|
|
&& CLOCK.test(value[1])
|
|
&& (dated || value[0] <= value[1]);
|
|
}
|
|
|
|
function isFiniteNumber(value: unknown): value is number {
|
|
return typeof value === "number" && Number.isFinite(value);
|
|
}
|
|
|
|
function isStringArray(value: unknown): value is readonly string[] {
|
|
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
}
|
|
|
|
function isNumberRecord(value: unknown): boolean {
|
|
return isRecord(value) && Object.values(value).every(isFiniteNumber);
|
|
}
|
|
|
|
function isDirectionRecord(value: unknown): boolean {
|
|
return isRecord(value) && Object.values(value).every((item) => (
|
|
typeof item === "string" && SCORE_DIRECTIONS.has(item)
|
|
));
|
|
}
|
|
|
|
function isInferenceEvent(value: unknown): boolean {
|
|
return isRecord(value)
|
|
&& typeof value.id === "string" && value.id.length > 0
|
|
&& typeof value.domain === "string" && value.domain.length > 0
|
|
&& (value.year === null || Number.isInteger(value.year))
|
|
&& typeof value.precision === "string" && EVENT_PRECISIONS.has(value.precision)
|
|
&& typeof value.usage === "string" && EVENT_USAGES.has(value.usage);
|
|
}
|
|
|
|
function isProbeOutcome(value: unknown): boolean {
|
|
return isRecord(value)
|
|
&& typeof value.answer_class === "string" && ANSWER_CLASSES.has(value.answer_class)
|
|
&& isStringArray(value.supports)
|
|
&& isStringArray(value.conflicts);
|
|
}
|
|
|
|
function isConflictProbe(value: unknown): boolean {
|
|
return isRecord(value)
|
|
&& typeof value.id === "string" && value.id.length > 0
|
|
&& typeof value.semantic_key === "string" && value.semantic_key.length > 0
|
|
&& typeof value.candidate_split_hash === "string" && value.candidate_split_hash.length > 0
|
|
&& typeof value.domain === "string" && value.domain.length > 0
|
|
&& Number.isInteger(value.year)
|
|
&& typeof value.question === "string" && value.question.length > 0
|
|
&& isStringArray(value.candidate_ids)
|
|
&& Array.isArray(value.expected_outcomes) && value.expected_outcomes.every(isProbeOutcome)
|
|
&& isFiniteNumber(value.information_gain)
|
|
&& typeof value.source === "string" && value.source.length > 0
|
|
&& (value.choice_kind === undefined
|
|
|| (typeof value.choice_kind === "string" && PROBE_CHOICE_KINDS.has(value.choice_kind)))
|
|
&& (value.carried === undefined || value.carried === true || value.carried === false)
|
|
&& (value.outcome_by_minute === undefined || isDirectionRecord(value.outcome_by_minute));
|
|
}
|
|
|
|
function isProbeAnswer(value: unknown): boolean {
|
|
return isRecord(value)
|
|
&& typeof value.probe_id === "string" && value.probe_id.length > 0
|
|
&& typeof value.semantic_key === "string" && value.semantic_key.length > 0
|
|
&& typeof value.candidate_split_hash === "string" && value.candidate_split_hash.length > 0
|
|
&& typeof value.answer_class === "string" && ANSWER_CLASSES.has(value.answer_class)
|
|
&& typeof value.classified_from === "string" && ANSWER_SOURCES.has(value.classified_from);
|
|
}
|
|
|
|
function isRoundTrace(value: unknown): boolean {
|
|
return isRecord(value)
|
|
&& Number.isInteger(value.round) && Number(value.round) >= 0
|
|
&& typeof value.phase === "string" && PHASES.has(value.phase)
|
|
&& (value.probe_id === null || typeof value.probe_id === "string")
|
|
&& isNumberRecord(value.scores_before)
|
|
&& isNumberRecord(value.scores_after)
|
|
&& (value.score_deltas === undefined || isNumberRecord(value.score_deltas))
|
|
&& isFiniteNumber(value.entropy_before)
|
|
&& isFiniteNumber(value.entropy_after)
|
|
&& isStringArray(value.eliminated_ids)
|
|
&& (value.winner_id === null || typeof value.winner_id === "string")
|
|
&& typeof value.kind === "string" && ROUND_KINDS.has(value.kind);
|
|
}
|
|
|
|
function isInferenceCandidate(value: unknown): boolean {
|
|
if (!isRecord(value)
|
|
|| typeof value.id !== "string" || value.id.length === 0
|
|
|| typeof value.time !== "string" || !CLOCK.test(value.time)
|
|
|| !isClockRange(value.cluster_range, value.candidate_date !== undefined)
|
|
|| (value.candidate_date !== undefined ? !readCandidatePosition(value)
|
|
: value.time < value.cluster_range[0] || value.time > value.cluster_range[1])
|
|
|| !isFiniteNumber(value.prior_score)
|
|
|| !isFiniteNumber(value.posterior_score)
|
|
|| !isFiniteNumber(value.probability) || value.probability < 0 || value.probability > 1
|
|
|| typeof value.status !== "string" || !CANDIDATE_STATUSES.has(value.status)
|
|
|| !Number.isInteger(value.rank) || Number(value.rank) < 1
|
|
|| !Number.isInteger(value.strong_conflict_count) || Number(value.strong_conflict_count) < 0) return false;
|
|
return true;
|
|
}
|
|
|
|
function canonicalSegmentJson(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(canonicalSegmentJson).join(",")}]`;
|
|
if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalSegmentJson(value[key])}`).join(",")}}`;
|
|
return JSON.stringify(value) ?? "null";
|
|
}
|
|
|
|
export function asInferenceState(value: unknown): InferenceState | null {
|
|
if (!isRecord(value)
|
|
|| value.algorithm_version !== INFERENCE_ALGORITHM_VERSION
|
|
|| typeof value.candidate_set_id !== "string" || value.candidate_set_id.trim().length === 0
|
|
|| !Number.isInteger(value.revision) || Number(value.revision) < 0
|
|
|| typeof value.phase !== "string" || !PHASES.has(value.phase)
|
|
|| typeof value.result_status !== "string" || !RESULT_STATUSES.has(value.result_status)
|
|
|| typeof value.range_start !== "string" || !CLOCK.test(value.range_start)
|
|
|| typeof value.range_end !== "string" || !CLOCK.test(value.range_end)
|
|
|| (!value.candidate_set_id.startsWith("dated-v1:") && value.range_start > value.range_end)
|
|
|| !Array.isArray(value.candidates) || value.candidates.length === 0
|
|
|| !value.candidates.every(isInferenceCandidate)
|
|
|| new Set(value.candidates.map((item) => (item as Readonly<Record<string, unknown>>).id)).size !== value.candidates.length
|
|
|| new Set(value.candidates.map((item) => (item as Readonly<Record<string, unknown>>).time)).size !== value.candidates.length
|
|
|| value.candidate_set_id !== candidateSetId(
|
|
value.range_start,
|
|
value.range_end,
|
|
value.candidates.map((item) => (item as Readonly<Record<string, unknown>>).time as string),
|
|
value.candidates as (CandidatePosition & { time: string })[],
|
|
)
|
|
|| !Array.isArray(value.events) || !value.events.every(isInferenceEvent)
|
|
|| !Array.isArray(value.probes) || !value.probes.every(isConflictProbe)
|
|
|| !Array.isArray(value.answered_probes) || !value.answered_probes.every(isProbeAnswer)
|
|
|| !Array.isArray(value.rounds) || !value.rounds.every(isRoundTrace)
|
|
|| !(value.last_inference_round === undefined
|
|
|| value.last_inference_round === null
|
|
|| isRoundTrace(value.last_inference_round))
|
|
|| !isFiniteNumber(value.entropy)
|
|
|| !(value.representative_time === null
|
|
|| (typeof value.representative_time === "string" && CLOCK.test(value.representative_time)))
|
|
|| !(value.credible_range === null || isClockRange(value.credible_range, value.candidate_set_id.startsWith("dated-v1:")))
|
|
|| (value.candidate_set_id.startsWith("dated-v1:") && (
|
|
!value.candidates.every((item) => readCandidatePosition(item as Record<string, unknown>))
|
|
|| !parseWindowIntervals(value.credible_intervals)
|
|
))) return null;
|
|
const state = value as InferenceState;
|
|
const candidateMetadataValid = state.candidates.every((candidate) => (
|
|
(candidate.raw_prior_score === undefined || isFiniteNumber(candidate.raw_prior_score))
|
|
&& (candidate.raw_posterior_score === undefined || isFiniteNumber(candidate.raw_posterior_score))
|
|
&& (candidate.raw_eliminated === undefined || typeof candidate.raw_eliminated === "boolean")
|
|
&& (candidate.signature === undefined || Boolean(parseAscendantSignature(candidate.signature)))
|
|
&& (candidate.cluster_times === undefined || (isStringArray(candidate.cluster_times)
|
|
&& candidate.cluster_times.every((time) => CLOCK.test(time))))
|
|
));
|
|
if (candidateMetadataValid && value.segment_summary === undefined && value.segment_minutes === undefined) return state;
|
|
const summary = parseSegmentSummary(value.segment_summary);
|
|
const minutes = value.segment_minutes;
|
|
let valid = Boolean(candidateMetadataValid && summary && Array.isArray(minutes) && minutes.length && minutes.every((minute) =>
|
|
isRecord(minute) && Number.isInteger(minute.offset) && Number(minute.offset) >= 0
|
|
&& typeof minute.time === "string" && CLOCK.test(minute.time) && parseAscendantSignature(minute.signs)
|
|
&& (minute.date === undefined || (typeof minute.date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(minute.date)
|
|
&& Number.isFinite(Date.parse(`${minute.date}T00:00:00Z`))
|
|
&& new Date(`${minute.date}T00:00:00Z`).toISOString().slice(0, 10) === minute.date)))
|
|
&& (value.segment_targets === undefined || (Array.isArray(value.segment_targets)
|
|
&& value.segment_targets.every((chart) => TARGET_CHARTS.includes(chart))
|
|
&& JSON.stringify(value.segment_targets) === JSON.stringify(summary?.targets)))
|
|
&& typeof value.segment_scan_complete === "boolean" && typeof value.segment_order_enabled === "boolean"
|
|
&& state.candidates.every((candidate) => candidate.raw_posterior_score !== undefined && typeof candidate.raw_eliminated === "boolean"));
|
|
if (valid && summary) {
|
|
const clock = (time: string) => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
|
|
const delivered = state.credible_intervals?.length
|
|
? state.credible_intervals.map((part) => [part.start_offset_minutes, part.end_offset_minutes] as const)
|
|
: state.credible_range ? [[(clock(state.credible_range[0]) - clock(state.range_start) + 1440) % 1440,
|
|
(clock(state.credible_range[1]) - clock(state.range_start) + 1440) % 1440] as const] : [];
|
|
const expected = buildSegmentSummary({ minutes: minutes as SegmentMinute[], targets: summary.targets,
|
|
windowMinutes: summary.window_minutes, scanComplete: state.segment_scan_complete === true, deliveredOffsets: delivered,
|
|
candidates: state.candidates.map((candidate) => ({ time: candidate.time, score: candidate.raw_posterior_score!,
|
|
cluster_times: candidate.cluster_times, eliminated: candidate.raw_eliminated })) });
|
|
valid = canonicalSegmentJson(summary) === canonicalSegmentJson(expected);
|
|
}
|
|
if (valid) return state;
|
|
// Optional chart metadata must never erase the authoritative frozen inference.
|
|
const clean = { ...state };
|
|
if (!candidateMetadataValid) clean.candidates = state.candidates.map((candidate) => {
|
|
const row = { ...candidate };
|
|
delete row.raw_prior_score;
|
|
delete row.raw_posterior_score;
|
|
delete row.raw_eliminated;
|
|
delete row.signature;
|
|
delete row.cluster_times;
|
|
return row;
|
|
});
|
|
delete clean.segment_summary;
|
|
delete clean.segment_minutes;
|
|
delete clean.segment_targets;
|
|
delete clean.segment_scan_complete;
|
|
delete clean.segment_order_enabled;
|
|
return clean;
|
|
}
|
|
|
|
/**
|
|
* Overlay the latest inference revision onto an immutable engine receipt.
|
|
* Only apply when the transition was recorded against this engine result.
|
|
*/
|
|
export function composeInferenceReceipt(
|
|
engineReceipt: Readonly<Record<string, unknown>> | null | undefined,
|
|
transition: InferenceTransitionSnapshot | null | undefined,
|
|
resultId?: string | null,
|
|
): Record<string, unknown> {
|
|
const receipt = engineReceipt && typeof engineReceipt === "object" && !Array.isArray(engineReceipt)
|
|
? { ...engineReceipt }
|
|
: {};
|
|
if (!transition) return receipt;
|
|
if (resultId && transition.resultId && transition.resultId !== resultId) return receipt;
|
|
const inferenceState = asInferenceState(transition.inferenceState);
|
|
if (!inferenceState || transition.revision !== inferenceState.revision) return receipt;
|
|
return {
|
|
...receipt,
|
|
inference_state: inferenceState,
|
|
decision_state_fingerprint: transition.decisionStateFingerprint,
|
|
};
|
|
}
|
|
|
|
export function previousInferenceFromReceipt(
|
|
receipt: Readonly<Record<string, unknown>> | null | undefined,
|
|
): InferenceState | null {
|
|
return asInferenceState(receipt?.inference_state);
|
|
}
|