9fc4edf3a9
The degraded grades (projection=0 → model_value=0) are already settled in the
append-only ledger and must NOT be deleted (Data Semantics law). But their
hit/miss is noise, not model skill — they never had a real projection. So
getModelAggregate now filters `.gt('model_value', 0)` on both the settled and
pending queries: the rows stay in ledger_entries, but leave the public hit_pct /
CLV / per-tier record. `.gt` also drops NULL model_value. Post-fix no such row
can be written (projection<=0 refuses), so this only sheds the historical set.
This is the functional form of the "marking" the work order asked for — the
degraded locks are effectively marked as non-counting without mutating history.
Test builder mocks gained `.gt`; a lock asserts the filter is applied to both
queries. Suite 269/3253 green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
78 lines
3.3 KiB
JavaScript
78 lines
3.3 KiB
JavaScript
// A1 Session 10 — getModelAggregate scoping. The public default MUST stay
|
|
// `.is('user_id', null)` (the model record); the new `userId` option swaps
|
|
// it for `.eq('user_id', uid)` (a user's own public-profile aggregate).
|
|
// Same window, same n≥20 gate in both scopes.
|
|
|
|
const ledgerService = require('../../src/services/ledgerService');
|
|
|
|
function makeSb(captured, rows) {
|
|
return {
|
|
from() {
|
|
const b = { _filters: [] };
|
|
const rec = (op) => (...args) => { b._filters.push([op, ...args]); return b; };
|
|
b.select = () => b;
|
|
b.eq = rec('eq');
|
|
b.is = rec('is');
|
|
b.not = rec('not');
|
|
b.gte = rec('gte');
|
|
b.gt = rec('gt');
|
|
b.order = () => b;
|
|
b.limit = () => { captured.push(b._filters); return Promise.resolve({ data: rows, error: null, count: 0 }); };
|
|
b.then = (resolve, reject) => {
|
|
captured.push(b._filters);
|
|
return Promise.resolve({ data: rows, error: null, count: 2 }).then(resolve, reject);
|
|
};
|
|
return b;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('getModelAggregate scoping', () => {
|
|
test('default (no userId) scopes BOTH queries to the public record (user_id IS NULL)', async () => {
|
|
const captured = [];
|
|
const agg = await ledgerService.getModelAggregate({ sb: makeSb(captured, []) });
|
|
expect(captured.length).toBe(2); // settled + pending
|
|
for (const filters of captured) {
|
|
expect(filters).toContainEqual(['is', 'user_id', null]);
|
|
expect(filters.some((f) => f[0] === 'eq' && f[1] === 'user_id')).toBe(false);
|
|
}
|
|
expect(agg.hit_pct).toBeNull();
|
|
expect(agg.min_sample).toBe(ledgerService.MIN_AGG_SAMPLE);
|
|
// 2026-07 — projection<=0 grades (the degradation blast radius) are excluded
|
|
// from the public record on BOTH queries (kept in the append-only ledger,
|
|
// just never counted). `.gt('model_value', 0)` also drops NULL model_value.
|
|
for (const filters of captured) {
|
|
expect(filters).toContainEqual(['gt', 'model_value', 0]);
|
|
}
|
|
});
|
|
|
|
test('userId scopes BOTH queries to that user and never touches the public scope', async () => {
|
|
const captured = [];
|
|
await ledgerService.getModelAggregate({ sb: makeSb(captured, []), userId: 'u-42' });
|
|
expect(captured.length).toBe(2);
|
|
for (const filters of captured) {
|
|
expect(filters).toContainEqual(['eq', 'user_id', 'u-42']);
|
|
expect(filters.some((f) => f[0] === 'is' && f[1] === 'user_id')).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('user scope keeps the n≥20 gate — no percentage on a small sample', async () => {
|
|
const rows = Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat', grade: 'A' }));
|
|
const agg = await ledgerService.getModelAggregate({ sb: makeSb([], rows), userId: 'u-42' });
|
|
expect(agg.settled).toBe(5);
|
|
expect(agg.hit_pct).toBeNull();
|
|
expect(agg.beat_close_pct).toBeNull();
|
|
});
|
|
|
|
test('user scope renders percentages at n≥20 like the public record', async () => {
|
|
const rows = [
|
|
...Array.from({ length: 14 }, () => ({ outcome: 'hit', clv_result: 'beat', grade: 'A' })),
|
|
...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded', grade: 'B' })),
|
|
];
|
|
const agg = await ledgerService.getModelAggregate({ sb: makeSb([], rows), userId: 'u-42' });
|
|
expect(agg.settled).toBe(21);
|
|
expect(agg.hit_pct).toBe(Math.round((14 / 21) * 100));
|
|
expect(agg.beat_close_pct).toBe(Math.round((14 / 21) * 100));
|
|
});
|
|
});
|