Files
timmy-talking-turd/src/model-metrics.js
T
timmy 3e2253a052 wip(#19): privacy-safe model metrics in progress — preserved snapshot before nightly release
Local work in progress, not reviewed and not part of any PR. Committed only so the
nightly release can build from a clean main without discarding it.
2026-11-01 12:02:43 +00:00

140 lines
5.7 KiB
JavaScript

// Privacy-safe model health metrics for issue #19.
//
// The point of this module is the boundary it does not cross: a metric sample is assembled from
// enumerated outcomes and numbers, never from a request. No note text, no photo hash, no image
// bytes, and no caller identity can reach a label, because the only strings this module stores are
// members of the frozen lists below. Anything else is coerced to a bucket instead of copied.
export const OUTCOMES = Object.freeze(['suggestion', 'abstained', 'failure']);
// Failure classes are the operator's vocabulary for "what went wrong". They are chosen here, not
// observed from an error, so an upstream message can never become a metric label.
export const FAILURE_CLASSES = Object.freeze([
'ingress_refused',
'rate_limited',
'disabled',
'provider_unreachable',
'provider_rejected',
'schema_invalid',
'timeout',
'unexpected',
]);
// Readiness is the same vocabulary the browser already uses for the AI affordance, so a metric and
// the screen can never disagree about why AI is withheld.
export const READINESS_PHASES = Object.freeze(['checking', 'disabled', 'offline', 'loading', 'ready']);
// Failure classes that are decided before a request can occupy a queue slot or wait on the model.
const REFUSED_BEFORE_MODEL = Object.freeze(['ingress_refused', 'rate_limited', 'disabled']);
export function createModelMetrics({ now = () => Date.now(), limit = 4 } = {}) {
const outcomes = { suggestion: 0, abstained: 0, failure: 0 };
const failureClasses = Object.fromEntries(FAILURE_CLASSES.map(name => [name, 0]));
// The queue boundary is tracked by opaque tickets, not by request identity: the only thing that
// survives a request here is the count of slots in use.
const inUse = new Set();
let accepted = 0;
const startedAt = now();
let readiness = { phase: 'checking', ready: false, coldLoadMs: null };
let analyzeRequests = 0;
let refusedBeforeModel = 0;
// Latency is a histogram, never a list of individual timings: a bucket count says how the worker
// behaves without carrying a per-request record of when someone photographed their stool.
const bucketEdgesMs = Object.freeze([1_000, 2_000, 5_000, 15_000, 60_000]);
const bucketsMs = Object.fromEntries(bucketEdgesMs.map(edge => [String(edge), 0]));
let latencyCount = 0;
let latencySumMs = 0;
let latencyMaxMs = 0;
function abstentionRate() {
const answered = outcomes.suggestion + outcomes.abstained;
return answered === 0 ? null : outcomes.abstained / answered;
}
function recordLatency(durationMs) {
if (!Number.isFinite(durationMs) || durationMs < 0) return;
latencyCount += 1;
latencySumMs += durationMs;
latencyMaxMs = Math.max(latencyMaxMs, durationMs);
// Edges are cumulative on purpose: `bucketsMs['5000']` answers "how many samples were at or
// below five seconds", so an operator can read a percentile without this module storing the
// individual timings that produced it.
for (const edge of bucketEdgesMs) {
if (durationMs <= edge) bucketsMs[String(edge)] += 1;
}
// Anything slower than the last edge stays counted in the total but out of the histogram, so a
// hanging request cannot invent a bucket.
}
return {
enter() {
const ticket = Symbol('model-slot');
inUse.add(ticket);
accepted += 1;
return ticket;
},
leave(ticket) {
// Deleting a ticket that is already gone is a no-op, so a double release cannot free a slot
// that a different request still holds.
inUse.delete(ticket);
},
recordReadiness({ phase, ready }) {
const next = READINESS_PHASES.includes(phase) ? phase : 'checking';
// A worker can flap between ready and offline. Only the first time the model became resident
// is a cold load; every later observation is just the current phase.
const coldLoadMs = readiness.coldLoadMs === null && next === 'ready' && ready === true
? Math.max(0, now() - startedAt)
: readiness.coldLoadMs;
readiness = { phase: next, ready: ready === true, coldLoadMs };
},
recordAnalyze({ outcome, failureClass, durationMs }) {
// The label is looked up, never stored: an unrecognised outcome becomes `failure` so the
// counters stay a closed set no matter what a caller sends.
const bucket = OUTCOMES.includes(outcome) ? outcome : 'failure';
outcomes[bucket] += 1;
analyzeRequests += 1;
if (bucket === 'failure') {
failureClasses[FAILURE_CLASSES.includes(failureClass) ? failureClass : 'unexpected'] += 1;
// A refusal decided before any model work (bad bytes, the per-device budget, or the disabled
// switch) is an app fact: it never occupied a queue slot and never waited on the model.
if (REFUSED_BEFORE_MODEL.includes(failureClass)) {
refusedBeforeModel += 1;
} else {
recordLatency(durationMs);
}
} else {
recordLatency(durationMs);
}
},
snapshot() {
return {
schema: 1,
generatedAt: new Date(now()).toISOString(),
app: {
uptimeMs: Math.max(0, now() - startedAt),
analyzeRequests,
refusedBeforeModel,
},
queue: {
inFlight: inUse.size,
limit,
atCapacity: inUse.size >= limit,
accepted,
},
model: {
outcomes: { ...outcomes },
failureClasses: { ...failureClasses },
abstentionRate: abstentionRate(),
readiness: { ...readiness },
latency: {
count: latencyCount,
meanMs: latencyCount === 0 ? null : Math.round(latencySumMs / latencyCount),
maxMs: latencyMaxMs,
bucketsMs: { ...bucketsMs },
},
},
};
},
};
}