fraim 2.0.346 → 2.0.348
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/registry/scripts/learning/completion-state.js +80 -4
- package/dist/registry/scripts/learning/corpus-audit.js +7 -7
- package/dist/registry/scripts/learning/entry-authoring.js +181 -0
- package/dist/registry/scripts/learning/entry-format.js +10 -1
- package/dist/registry/scripts/learning/outcome-assessment.js +162 -0
- package/dist/registry/scripts/learning/outcome-types.js +92 -0
- package/dist/registry/scripts/learning/owner-identity.js +11 -0
- package/dist/registry/scripts/learning/proposal-queue.js +81 -0
- package/dist/registry/scripts/learning/run-application.js +1003 -0
- package/dist/registry/scripts/learning/run-publication.js +364 -0
- package/dist/registry/scripts/learning/run-record.js +895 -0
- package/dist/registry/scripts/learning-usage/learning-source-record.js +131 -0
- package/dist/src/cli/commands/learning.js +64 -0
- package/dist/src/cli/doctor/checks/scripts-checks.js +53 -1
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/learning/inventory.js +285 -0
- package/dist/src/cli/learning/queue-operations.js +199 -0
- package/dist/src/cli/learning/runtime.js +82 -0
- package/dist/src/cli/learning/usage.js +277 -0
- package/dist/src/core/fraim-config-schema.generated.js +0 -4
- package/dist/src/local-mcp-server/learning-context-builder.js +91 -377
- package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
- package/dist/src/local-mcp-server/learning-usage-analysis.js +12 -410
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
- package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
- package/dist/src/local-mcp-server/learning-usage-verify.js +9 -40
- package/dist/src/local-mcp-server/stdio-server.js +0 -48
- package/package.json +9 -1
- package/dist/src/local-mcp-server/learning-firing-parser.js +0 -247
- package/dist/src/local-mcp-server/learning-usage-attestation.js +0 -238
- package/dist/src/local-mcp-server/learning-usage-command.js +0 -518
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LEARNING_ENTRY_ID_LINE = exports.LEARNING_ENTRY_ID_REGEX = exports.LEARNING_PRIORITIES = void 0;
|
|
4
|
+
exports.learningEntryHeadingRegex = learningEntryHeadingRegex;
|
|
5
|
+
exports.learningEntryIdLineRegex = learningEntryIdLineRegex;
|
|
6
|
+
exports.generateLearningEntryId = generateLearningEntryId;
|
|
7
|
+
/** Stable learning IDs and headings shared by corpus readers and explicit stamping. */
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
exports.LEARNING_PRIORITIES = ['P-CRITICAL', 'P-HIGH', 'P-MED', 'P-LOW'];
|
|
10
|
+
function learningEntryHeadingRegex(flags = '') {
|
|
11
|
+
return new RegExp(`^#{2,}\\s+\\[(${exports.LEARNING_PRIORITIES.join('|')})\\]\\s*(.*)$`, flags);
|
|
12
|
+
}
|
|
13
|
+
/** Generated IDs are deterministic on first stamping; persisted IDs survive title edits. */
|
|
14
|
+
exports.LEARNING_ENTRY_ID_REGEX = /^L-[0-9a-f]{10}$/;
|
|
15
|
+
/** Readers honor previously handwritten IDs as well as generated IDs. */
|
|
16
|
+
exports.LEARNING_ENTRY_ID_LINE = /^\*\*Id\*\*:\s*(L-[0-9A-Za-z]{6,24})\s*$/;
|
|
17
|
+
function learningEntryIdLineRegex() {
|
|
18
|
+
return new RegExp(exports.LEARNING_ENTRY_ID_LINE.source);
|
|
19
|
+
}
|
|
20
|
+
function generateLearningEntryId(fileType, title) {
|
|
21
|
+
const digest = (0, node_crypto_1.createHash)('sha1')
|
|
22
|
+
.update(`${fileType}|${title.trim().replace(/\s+/g, ' ').toLowerCase()}`)
|
|
23
|
+
.digest('hex');
|
|
24
|
+
return `L-${digest.slice(0, 10)}`;
|
|
25
|
+
}
|
|
@@ -1,389 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.classifyRecurrence = classifyRecurrence;
|
|
4
|
-
exports.resolveStructuralProbe = resolveStructuralProbe;
|
|
5
|
-
exports.findRetirementCandidates = findRetirementCandidates;
|
|
6
|
-
exports.deriveStanding = deriveStanding;
|
|
7
|
-
exports.assessStanding = assessStanding;
|
|
8
3
|
exports.buildUsageReport = buildUsageReport;
|
|
9
|
-
/**
|
|
10
|
-
* Issue #1103 — what the usage record lets you conclude.
|
|
11
|
-
*
|
|
12
|
-
* Three questions, each with an answer the record can prove:
|
|
13
|
-
*
|
|
14
|
-
* R7 Why did this recur? `ignored` means the entry was in context and did not
|
|
15
|
-
* change the outcome, so the fix is the entry. `not-offered` means it never
|
|
16
|
-
* reached the agent, so the fix is the delivery. Today a recurrence cannot
|
|
17
|
-
* tell those apart, which is why the same lesson gets rewritten when the
|
|
18
|
-
* real bug is in the loader.
|
|
19
|
-
* R9 Which entries have stopped earning their place under the current model?
|
|
20
|
-
* R20 And is there a second, independent signal agreeing before anything is
|
|
21
|
-
* recommended for retirement?
|
|
22
|
-
*
|
|
23
|
-
* Everything here is read-only. R10 and R19: usage data produces recommendations,
|
|
24
|
-
* and every change to an entry still passes the existing review gate.
|
|
25
|
-
*/
|
|
26
|
-
const fs_1 = require("fs");
|
|
27
|
-
const path_1 = require("path");
|
|
4
|
+
/** Optional historical outcome reporting. */
|
|
28
5
|
const learning_usage_store_1 = require("./learning-usage-store");
|
|
29
|
-
const learning_usage_projection_1 = require("./learning-usage-projection");
|
|
30
6
|
const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
|
|
31
|
-
function
|
|
32
|
-
const a = new Date(from).getTime();
|
|
33
|
-
const b = new Date(to).getTime();
|
|
34
|
-
if (Number.isNaN(a) || Number.isNaN(b))
|
|
35
|
-
return Number.POSITIVE_INFINITY;
|
|
36
|
-
return Math.abs(b - a) / 86_400_000;
|
|
37
|
-
}
|
|
7
|
+
function latestTitle(record) { return record.titles.at(-1) ?? record.key; }
|
|
38
8
|
function firedCount(record) {
|
|
39
9
|
return learning_usage_store_1.CLOCK_ADVANCING_OUTCOMES.reduce((sum, outcome) => sum + record.fired.byOutcome[outcome], 0);
|
|
40
10
|
}
|
|
41
|
-
function classifyRecurrence(store, options) {
|
|
42
|
-
const record = store.entries[options.key];
|
|
43
|
-
const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
|
|
44
|
-
const base = {
|
|
45
|
-
key: options.key,
|
|
46
|
-
title: record?.titles[record.titles.length - 1] ?? null,
|
|
47
|
-
evidence: {
|
|
48
|
-
recurrenceDate: options.date,
|
|
49
|
-
recurrenceJob: options.job,
|
|
50
|
-
windowDays: options.windowDays,
|
|
51
|
-
matchingOffers: [],
|
|
52
|
-
offeredTotal: record?.offered.total ?? 0,
|
|
53
|
-
firedTotal: record ? firedCount(record) : 0,
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
if (record && excluded.has(record.key)) {
|
|
57
|
-
return {
|
|
58
|
-
...base, classification: 'undetermined',
|
|
59
|
-
diagnosis: 'The available usage evidence cannot establish this recurrence.',
|
|
60
|
-
recommendation: 'Review independent source evidence without attributing this recurrence to usage.',
|
|
61
|
-
recurrenceBump: false, targetOfFix: 'none',
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
if (!record) {
|
|
65
|
-
return {
|
|
66
|
-
...base,
|
|
67
|
-
classification: 'undetermined',
|
|
68
|
-
diagnosis: 'There is no usage record for this entry, so whether it was in context for that job cannot be determined.',
|
|
69
|
-
recommendation: 'No usage record exists for this entry yet. Treat this recurrence the way you would have before usage attribution, and re-check after the entry has been offered a few times.',
|
|
70
|
-
recurrenceBump: true,
|
|
71
|
-
targetOfFix: 'none',
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
// "In context for that job" means an offer for the same job inside the window.
|
|
75
|
-
// A different job's offer is not evidence: domain filtering is exactly the
|
|
76
|
-
// mechanism that can deliver an entry to one job and withhold it from another.
|
|
77
|
-
const matchingOffers = record.offered.log.filter((offer) => offer.job === options.job
|
|
78
|
-
&& offer.aboveThreshold
|
|
79
|
-
&& daysBetween(offer.date, options.date) <= options.windowDays);
|
|
80
|
-
base.evidence.matchingOffers = matchingOffers;
|
|
81
|
-
// Absence of a matching offer record is only evidence of absence when the offer
|
|
82
|
-
// log actually reaches back over the window. The log is bounded (R16a), so an
|
|
83
|
-
// entry offered many times can have had every relevant record trimmed away.
|
|
84
|
-
// Reporting `not-offered` there would tell the manager to fix the loader for an
|
|
85
|
-
// entry that was in fact delivered, which is the wrong-fix failure R7 exists to
|
|
86
|
-
// prevent. The history is complete when nothing has been trimmed, or when the
|
|
87
|
-
// oldest retained record predates the window.
|
|
88
|
-
const windowStartMs = new Date(options.date).getTime() - options.windowDays * 86_400_000;
|
|
89
|
-
const oldestRetained = record.offered.log.length
|
|
90
|
-
? record.offered.log.reduce((min, o) => (o.date < min ? o.date : min), record.offered.log[0].date)
|
|
91
|
-
: null;
|
|
92
|
-
const historyComplete = record.offered.log.length === record.offered.total
|
|
93
|
-
|| (oldestRetained !== null && new Date(oldestRetained).getTime() <= windowStartMs);
|
|
94
|
-
// #1752 D2, the issue's suggested fix 4. `report` selects an entry into
|
|
95
|
-
// `ignoredWhileInContext` on `byOutcome.ignored > 0`, which asserts the entry
|
|
96
|
-
// was in context; `classify` was answering `not-offered` for the same entry,
|
|
97
|
-
// which asserts the opposite, with no hedge. The #1740 postmortem states in
|
|
98
|
-
// first-hand prose that L-abc958f241 "was above threshold and in context".
|
|
99
|
-
// When the two halves of one record disagree, neither is evidence.
|
|
100
|
-
const ignoredInWindow = record.fired.log.filter((firing) => firing.outcome === 'ignored' && daysBetween(firing.date, options.date) <= options.windowDays);
|
|
101
|
-
// #1752 D1. Absence of an offer for this job is only evidence when the record
|
|
102
|
-
// can resolve this job at all. Measured: 2067 of 2354 retained offer records
|
|
103
|
-
// carry `session` or `__freeform__`, because `fraim_connect` writes one offer
|
|
104
|
-
// per entry per session and a plain newest-N bound evicted the job-labelled
|
|
105
|
-
// ones within about a day. Asking about a job the store has never retained an
|
|
106
|
-
// offer for is a gap in the record, not a delivery failure.
|
|
107
|
-
const jobCoverage = Object.values(store.entries).some((entry) => !excluded.has(entry.key) && entry.offered.log.some((offer) => offer.job === options.job && daysBetween(offer.date, options.date) <= options.windowDays));
|
|
108
|
-
if (matchingOffers.length > 0) {
|
|
109
|
-
return {
|
|
110
|
-
...base,
|
|
111
|
-
classification: 'ignored',
|
|
112
|
-
diagnosis: `This entry was in context for ${options.job} when the mistake recurred, and being in context did not change what happened.`,
|
|
113
|
-
recommendation: 'The entry reached the agent and did not change the outcome, so the entry itself is what needs work. Fix its wording so the required action is unambiguous, or fix its placement so it is read at the moment the decision is made. Do not record another recurrence as if the lesson were simply not learned yet.',
|
|
114
|
-
recurrenceBump: true,
|
|
115
|
-
targetOfFix: 'entry',
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
if (ignoredInWindow.length > 0) {
|
|
119
|
-
return {
|
|
120
|
-
...base,
|
|
121
|
-
classification: 'undetermined',
|
|
122
|
-
diagnosis: `The two halves of this entry's record disagree: it carries ${ignoredInWindow.length} attested \`ignored\` outcome${ignoredInWindow.length === 1 ? '' : 's'} inside the window, which says it was in context and the work went against it, while the offer log holds no matching offer for ${options.job}, which says it was never delivered. Both cannot be true.`,
|
|
123
|
-
recommendation: 'Do not act on either reading. An `ignored` attestation and an empty offer log are contradictory evidence about the same event, so the record cannot say whether the entry or the delivery is at fault. Run `learning-usage verify` to see whether this entry carries an integrity flag, and treat the recurrence on its own terms until the record agrees with itself.',
|
|
124
|
-
recurrenceBump: true,
|
|
125
|
-
targetOfFix: 'none',
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
if (!jobCoverage) {
|
|
129
|
-
return {
|
|
130
|
-
...base,
|
|
131
|
-
classification: 'undetermined',
|
|
132
|
-
diagnosis: `No offer anywhere in the record carries the job label ${options.job} inside this window, so the per-job offer record has no resolution for that job and cannot say whether this entry was in context for it.`,
|
|
133
|
-
recommendation: 'The offer record cannot answer this question for this job, so absence of a match is not evidence of absence. Do not conclude a delivery problem. Re-ask once the job has run with offer recording in place, or widen the window to one the record covers.',
|
|
134
|
-
recurrenceBump: true,
|
|
135
|
-
targetOfFix: 'none',
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
if (!historyComplete) {
|
|
139
|
-
return {
|
|
140
|
-
...base,
|
|
141
|
-
classification: 'undetermined',
|
|
142
|
-
diagnosis: `Whether this entry was in context for ${options.job} cannot be determined: it has been offered ${record.offered.total} times, but the retained offer log no longer reaches back over the window being asked about.`,
|
|
143
|
-
recommendation: 'The offer history for this window has been pruned, so absence of a record is not evidence that the entry was not delivered. Do not conclude a delivery problem from this. Either widen the retained offer log for this corpus, or ask again about a more recent recurrence.',
|
|
144
|
-
recurrenceBump: true,
|
|
145
|
-
targetOfFix: 'none',
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
return {
|
|
149
|
-
...base,
|
|
150
|
-
classification: 'not-offered',
|
|
151
|
-
diagnosis: `This entry was never in context for ${options.job}, so it had no opportunity to change what happened.`,
|
|
152
|
-
recommendation: 'The entry never reached the agent, so the delivery is what is wrong and the entry text is fine. Check the entry\'s domain and its threshold in the loader: widen the domain if the lesson applies beyond the jobs it is currently scoped to. Do not raise the recurrence count, because the count measures how often the lesson failed to stick and this is not an instance of that.',
|
|
153
|
-
recurrenceBump: false,
|
|
154
|
-
targetOfFix: 'delivery',
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
const UNDETERMINED = {
|
|
158
|
-
signal: 'undetermined',
|
|
159
|
-
detail: 'The structural signal could not be determined: the corpus hygiene detector is not available in this workspace, so whether the thing this entry guards still exists is unknown.',
|
|
160
|
-
};
|
|
161
|
-
/**
|
|
162
|
-
* R20's structural half comes from issue #1100's inert and orphan detection, which
|
|
163
|
-
* answers a different question from usage: is the thing this entry guards still
|
|
164
|
-
* there? The two specs meet at exactly this point, and #1103 reads that detector
|
|
165
|
-
* rather than re-deriving it.
|
|
166
|
-
*
|
|
167
|
-
* When the detector is absent the probe says so plainly. That is not a fallback
|
|
168
|
-
* for convenience: R20's third acceptance criterion requires the undetermined case
|
|
169
|
-
* to be stated rather than presented as if both signals agreed.
|
|
170
|
-
*/
|
|
171
|
-
/**
|
|
172
|
-
* The only module this integration loads. Naming it exactly, rather than accepting
|
|
173
|
-
* any path, keeps `require` of a caller-supplied string from becoming a general
|
|
174
|
-
* arbitrary-module loader: a mistyped or hostile `--detector` value fails closed to
|
|
175
|
-
* the undetermined signal instead of executing.
|
|
176
|
-
*/
|
|
177
|
-
const STRUCTURAL_DETECTOR_FILENAME = 'instruction-hygiene.js';
|
|
178
|
-
function resolveStructuralProbe(detectorPath) {
|
|
179
|
-
if (!detectorPath || !(0, fs_1.existsSync)(detectorPath)) {
|
|
180
|
-
return () => UNDETERMINED;
|
|
181
|
-
}
|
|
182
|
-
if ((0, path_1.basename)(detectorPath) !== STRUCTURAL_DETECTOR_FILENAME) {
|
|
183
|
-
return () => ({
|
|
184
|
-
signal: 'undetermined',
|
|
185
|
-
detail: `The structural signal could not be determined: the detector path must end in ${STRUCTURAL_DETECTOR_FILENAME}, and this one does not.`,
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
let detector;
|
|
189
|
-
try {
|
|
190
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
191
|
-
detector = require(detectorPath);
|
|
192
|
-
}
|
|
193
|
-
catch {
|
|
194
|
-
return () => UNDETERMINED;
|
|
195
|
-
}
|
|
196
|
-
if (!detector || typeof detector.analyzeInstructionHygiene !== 'function') {
|
|
197
|
-
return () => UNDETERMINED;
|
|
198
|
-
}
|
|
199
|
-
return (key, title) => {
|
|
200
|
-
try {
|
|
201
|
-
const report = detector.analyzeInstructionHygiene({ root: process.cwd() });
|
|
202
|
-
const findings = report?.findings ?? [];
|
|
203
|
-
const needle = (title ?? key).toLowerCase();
|
|
204
|
-
const inert = findings.find((f) => (f.type === 'inert' || f.type === 'orphan')
|
|
205
|
-
&& `${f.file ?? ''} ${f.message ?? ''}`.toLowerCase().includes(needle));
|
|
206
|
-
if (inert) {
|
|
207
|
-
return { signal: 'guard-absent', detail: `The corpus hygiene detector reports this as ${inert.type}: ${inert.message ?? 'no consumer found'}.` };
|
|
208
|
-
}
|
|
209
|
-
return { signal: 'still-referenced', detail: 'The corpus hygiene detector finds the surface this entry guards is still referenced.' };
|
|
210
|
-
}
|
|
211
|
-
catch {
|
|
212
|
-
return UNDETERMINED;
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
function offersUnderModel(record, model) {
|
|
217
|
-
if (!model)
|
|
218
|
-
return 0;
|
|
219
|
-
return record.offered.byModel[model] ?? 0;
|
|
220
|
-
}
|
|
221
|
-
function firingsUnderModel(record, model) {
|
|
222
|
-
if (!model)
|
|
223
|
-
return 0;
|
|
224
|
-
return record.fired.byModel[model] ?? 0;
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* #1752 C4 - is this entry's per-model attribution unknown, as opposed to zero?
|
|
228
|
-
*
|
|
229
|
-
* All 781 retained firing records in the live store carry `agent: null,
|
|
230
|
-
* model: null`, because `attest-learning-firings.md` invoked `record-firings`
|
|
231
|
-
* with no `--agent` or `--model`. `firedUnderCurrentModel` was therefore
|
|
232
|
-
* structurally 0 for every entry, which is why `candidates` proposed 31
|
|
233
|
-
* retirements: every entry clearing the offer threshold became one, however hard
|
|
234
|
-
* it was working. Fixing the `currentModel` resolution alone would not have
|
|
235
|
-
* changed a single one of them.
|
|
236
|
-
*
|
|
237
|
-
* The predicate is deliberately narrower than "fired at all". An entry whose only
|
|
238
|
-
* outcomes are `ignored` or `not-applicable` has an empty `byModel` because those
|
|
239
|
-
* outcomes correctly do not bucket by model; that is information, not a gap. Only
|
|
240
|
-
* a clock-advancing firing that produced no bucket means the model went
|
|
241
|
-
* unrecorded.
|
|
242
|
-
*/
|
|
243
|
-
function modelAttributionUnknown(record) {
|
|
244
|
-
return (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record);
|
|
245
|
-
}
|
|
246
|
-
function earlierModelFirings(record, currentModel) {
|
|
247
|
-
return Object.entries(record.fired.byModel)
|
|
248
|
-
.filter(([model]) => model !== currentModel)
|
|
249
|
-
.map(([model, count]) => ({ model, count }))
|
|
250
|
-
.sort((a, b) => b.count - a.count);
|
|
251
|
-
}
|
|
252
|
-
function latestTitle(record) {
|
|
253
|
-
return record.titles[record.titles.length - 1] ?? record.key;
|
|
254
|
-
}
|
|
255
|
-
function findRetirementCandidates(store, options) {
|
|
256
|
-
const currentModel = (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel);
|
|
257
|
-
const threshold = options.limits.retirementOfferThreshold;
|
|
258
|
-
const probe = options.structuralProbe ?? resolveStructuralProbe(options.structuralSignalPath);
|
|
259
|
-
// #1752 C1: retirement candidacy is computed per model, so with no model there
|
|
260
|
-
// is no question to answer. An empty list with the reason stated is the honest
|
|
261
|
-
// answer; computing against a null bucket is how defect 3 produced 31
|
|
262
|
-
// proposals nobody could act on.
|
|
263
|
-
if (!currentModel) {
|
|
264
|
-
return {
|
|
265
|
-
currentModel: null,
|
|
266
|
-
offerThreshold: threshold,
|
|
267
|
-
candidates: [],
|
|
268
|
-
notCandidates: [],
|
|
269
|
-
reason: 'The current model did not resolve, so no per-model conclusion can be drawn. Pass --model <model>, or set FRAIM_ACTIVE_MODEL. The current model is never inferred from the usage record, because the record is shared by concurrent sessions from more than one agent and would report whichever wrote last.',
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
const candidates = [];
|
|
273
|
-
const notCandidates = [];
|
|
274
|
-
const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
|
|
275
|
-
for (const record of Object.values(store.entries)) {
|
|
276
|
-
if (record.family !== 'learning')
|
|
277
|
-
continue;
|
|
278
|
-
const title = latestTitle(record);
|
|
279
|
-
if (excluded.has(record.key) || modelAttributionUnknown(record)) {
|
|
280
|
-
notCandidates.push({ key: record.key, title,
|
|
281
|
-
reason: excluded.get(record.key) ?? 'Model attribution is incomplete; unknown use is not zero use.' });
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
const offered = offersUnderModel(record, currentModel);
|
|
285
|
-
const fired = firingsUnderModel(record, currentModel);
|
|
286
|
-
if (fired > 0) {
|
|
287
|
-
notCandidates.push({ key: record.key, title, reason: `It fired ${fired} time${fired === 1 ? '' : 's'} under the current model, so it is still earning its place.` });
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
if (offered < threshold) {
|
|
291
|
-
notCandidates.push({
|
|
292
|
-
key: record.key,
|
|
293
|
-
title,
|
|
294
|
-
reason: `Only ${offered} offer${offered === 1 ? '' : 's'} under the current model, which is below the threshold of ${threshold}. That is more likely to mean the work that triggers it has not come up than that it is no longer needed.`,
|
|
295
|
-
});
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
298
|
-
const structural = probe(record.key, title);
|
|
299
|
-
// R20: two independent signals before recommending removal. Usage alone says
|
|
300
|
-
// "unused"; only usage plus an absent guard says "obsolete".
|
|
301
|
-
const recommendation = structural.signal === 'guard-absent' ? 'retire' : 'keep-and-reword';
|
|
302
|
-
candidates.push({
|
|
303
|
-
key: record.key,
|
|
304
|
-
title,
|
|
305
|
-
offeredUnderCurrentModel: offered,
|
|
306
|
-
firedUnderCurrentModel: fired,
|
|
307
|
-
firedUnderEarlierModels: earlierModelFirings(record, currentModel),
|
|
308
|
-
currentModel,
|
|
309
|
-
structural: structural.signal,
|
|
310
|
-
structuralDetail: structural.detail,
|
|
311
|
-
recommendation,
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
candidates.sort((a, b) => b.offeredUnderCurrentModel - a.offeredUnderCurrentModel);
|
|
315
|
-
return { currentModel, offerThreshold: threshold, candidates, notCandidates };
|
|
316
|
-
}
|
|
317
|
-
/**
|
|
318
|
-
* The window in which a firing still counts as recent for standing purposes.
|
|
319
|
-
* Longer than the Brain's 30-day "fired recently" glance signal, because standing
|
|
320
|
-
* is a slower-moving decision than a dot colour.
|
|
321
|
-
*/
|
|
322
|
-
const STANDING_RECENT_DAYS = 90;
|
|
323
|
-
function daysSince(date, now) {
|
|
324
|
-
if (!date)
|
|
325
|
-
return Number.POSITIVE_INFINITY;
|
|
326
|
-
const parsed = new Date(date).getTime();
|
|
327
|
-
if (Number.isNaN(parsed))
|
|
328
|
-
return Number.POSITIVE_INFINITY;
|
|
329
|
-
return (now.getTime() - parsed) / 86_400_000;
|
|
330
|
-
}
|
|
331
|
-
function deriveStanding(record, options) {
|
|
332
|
-
const now = options.now ?? new Date();
|
|
333
|
-
const fired = firedCount(record);
|
|
334
|
-
const offered = record.offered.aboveThreshold;
|
|
335
|
-
const title = latestTitle(record);
|
|
336
|
-
const threshold = options.limits.retirementOfferThreshold;
|
|
337
|
-
if ((0, learning_usage_eligibility_1.countEvidenceProblem)(record, options.offersEraStart)) {
|
|
338
|
-
return { key: record.key, title, standing: options.currentStanding?.(record.key) ?? 'standard',
|
|
339
|
-
reason: 'No usage-based change is supported.', offered: null, fired: null, recommendation: null };
|
|
340
|
-
}
|
|
341
|
-
let standing;
|
|
342
|
-
let reason;
|
|
343
|
-
if (fired > 0 && daysSince(record.fired.lastFiredAt, now) <= STANDING_RECENT_DAYS) {
|
|
344
|
-
standing = 'promoted';
|
|
345
|
-
reason = `Fired ${fired} time${fired === 1 ? '' : 's'}, most recently ${record.fired.lastFiredAt}. It is changing outcomes, so it stays fully in context.`;
|
|
346
|
-
}
|
|
347
|
-
else if (offered >= threshold && fired === 0) {
|
|
348
|
-
standing = 'demoted';
|
|
349
|
-
reason = `Offered ${offered} times as active guidance and never changed an outcome. Demoted rather than removed: it stays readable and can earn its way back the first time it fires.`;
|
|
350
|
-
}
|
|
351
|
-
else if (fired > 0) {
|
|
352
|
-
standing = 'standard';
|
|
353
|
-
reason = `Fired ${fired} time${fired === 1 ? '' : 's'}, but not within the last ${STANDING_RECENT_DAYS} days. Held at standard while it is neither proving itself nor failing to.`;
|
|
354
|
-
}
|
|
355
|
-
else {
|
|
356
|
-
standing = 'standard';
|
|
357
|
-
reason = offered === 0
|
|
358
|
-
? 'No usage record yet. An entry FRAIM has not measured is held at standard rather than judged.'
|
|
359
|
-
: `Offered ${offered} times, which is below the ${threshold} needed to conclude anything from silence.`;
|
|
360
|
-
}
|
|
361
|
-
const current = options.currentStanding ? options.currentStanding(record.key) : 'standard';
|
|
362
|
-
const recommendation = standing === current ? null : standing === 'promoted' ? 'promote' : standing === 'demoted' ? 'demote' : null;
|
|
363
|
-
return { key: record.key, title, standing, reason, offered, fired, recommendation };
|
|
364
|
-
}
|
|
365
|
-
/**
|
|
366
|
-
* Assess every entry with a usage record. Read-only: this recommends, and
|
|
367
|
-
* `sleep-on-learnings` applies under the existing approval gate (R10, R19).
|
|
368
|
-
*/
|
|
369
|
-
function assessStanding(store, options) {
|
|
370
|
-
const now = options.now ?? new Date();
|
|
371
|
-
const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
|
|
372
|
-
const assessments = Object.values(store.entries)
|
|
373
|
-
.filter((r) => r.family === 'learning' && !excluded.has(r.key))
|
|
374
|
-
.map((r) => deriveStanding(r, { ...options, now, offersEraStart: store.offersEraStart }))
|
|
375
|
-
.sort((a, b) => (b.fired ?? 0) - (a.fired ?? 0) || (b.offered ?? 0) - (a.offered ?? 0));
|
|
376
|
-
return {
|
|
377
|
-
generatedAt: now.toISOString(),
|
|
378
|
-
assessments,
|
|
379
|
-
changes: assessments.filter((a) => a.recommendation !== null),
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
11
|
function toReportEntry(record) {
|
|
383
12
|
return {
|
|
384
13
|
key: record.key,
|
|
385
14
|
title: latestTitle(record),
|
|
386
|
-
offered: record.offered.total,
|
|
387
15
|
fired: firedCount(record),
|
|
388
16
|
ignored: record.fired.byOutcome.ignored,
|
|
389
17
|
lastFired: record.fired.lastFiredAt,
|
|
@@ -392,49 +20,23 @@ function toReportEntry(record) {
|
|
|
392
20
|
};
|
|
393
21
|
}
|
|
394
22
|
/**
|
|
395
|
-
*
|
|
23
|
+
* Historical outcome observations without fresh effectiveness or rank judgments.
|
|
396
24
|
*
|
|
397
25
|
* Only entries with a usage record appear. An entry FRAIM has never measured is
|
|
398
26
|
* absent rather than listed with zeroes, because a fabricated zero reads as
|
|
399
27
|
* evidence of disuse when it is only evidence of no data.
|
|
400
28
|
*/
|
|
401
|
-
function buildUsageReport(store, options) {
|
|
29
|
+
function buildUsageReport(store, options = {}) {
|
|
402
30
|
const now = options.now ?? new Date();
|
|
403
|
-
const limit = options.limit ?? 10;
|
|
404
31
|
const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const fired = learningRecords.filter((r) => firedCount(r) > 0);
|
|
410
|
-
const neverFired = offered.filter((r) => firedCount(r) === 0);
|
|
411
|
-
const ignored = learningRecords.filter((r) => r.fired.byOutcome.ignored > 0);
|
|
32
|
+
const records = Object.values(store.entries).filter(record => record.family === 'learning' && !excluded.has(record.key));
|
|
33
|
+
const historicalOutcomes = records.filter(record => record.fired.total > 0)
|
|
34
|
+
.sort((a, b) => (b.fired.lastAttestedAt ?? '').localeCompare(a.fired.lastAttestedAt ?? ''))
|
|
35
|
+
.slice(0, options.limit ?? 10).map(toReportEntry);
|
|
412
36
|
return {
|
|
413
|
-
generatedAt: now.toISOString(),
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
totals: {
|
|
418
|
-
entriesOffered: complete ? offered.length : null,
|
|
419
|
-
entriesWithFirings: complete ? fired.length : null,
|
|
420
|
-
entriesNeverFired: complete ? neverFired.length : null,
|
|
421
|
-
},
|
|
422
|
-
workingHardest: fired
|
|
423
|
-
.sort((a, b) => {
|
|
424
|
-
const byCount = firedCount(b) - firedCount(a);
|
|
425
|
-
if (byCount !== 0)
|
|
426
|
-
return byCount;
|
|
427
|
-
return (b.fired.lastFiredAt ?? '').localeCompare(a.fired.lastFiredAt ?? '');
|
|
428
|
-
})
|
|
429
|
-
.slice(0, limit)
|
|
430
|
-
.map(toReportEntry),
|
|
431
|
-
neverFired: neverFired
|
|
432
|
-
.sort((a, b) => b.offered.total - a.offered.total)
|
|
433
|
-
.slice(0, limit)
|
|
434
|
-
.map(toReportEntry),
|
|
435
|
-
ignoredWhileInContext: ignored
|
|
436
|
-
.sort((a, b) => b.fired.byOutcome.ignored - a.fired.byOutcome.ignored)
|
|
437
|
-
.slice(0, limit)
|
|
438
|
-
.map(toReportEntry),
|
|
37
|
+
generatedAt: now.toISOString(), historical: true,
|
|
38
|
+
lastObservedAt: records.map(record => record.fired.lastAttestedAt)
|
|
39
|
+
.filter((date) => Boolean(date) && Number.isFinite(Date.parse(date))).sort().at(-1) ?? null,
|
|
40
|
+
excludedCount: excluded.size, historicalOutcomes,
|
|
439
41
|
};
|
|
440
42
|
}
|
|
@@ -7,28 +7,22 @@ const learning_usage_store_1 = require("./learning-usage-store");
|
|
|
7
7
|
const validCount = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
8
8
|
/** Missing attribution is unknown, including a known bucket beside unattributed history. */
|
|
9
9
|
function modelAttributionIncomplete(record) {
|
|
10
|
-
const buckets = [record.
|
|
10
|
+
const buckets = [record.fired.byModel];
|
|
11
11
|
if (buckets.some(map => !map || Object.entries(map).some(([name, n]) => !validCount(n) || !(0, learning_usage_store_1.normalizeModelName)(name) || (0, learning_usage_store_1.normalizeModelName)(name) !== name)))
|
|
12
12
|
return true;
|
|
13
|
-
return Object.values(record.
|
|
14
|
-
|
|
15
|
-
!== record.fired.byOutcome.applied + record.fired.byOutcome.prevented;
|
|
13
|
+
return Object.values(record.fired.byModel).reduce((a, b) => a + b, 0)
|
|
14
|
+
!== record.fired.byOutcome.applied + record.fired.byOutcome.prevented;
|
|
16
15
|
}
|
|
17
16
|
/** Validate counts independently of a cached integrity flag. Never alter the record. */
|
|
18
|
-
function countEvidenceProblem(record
|
|
19
|
-
const counts = [record.
|
|
20
|
-
...Object.values(record.
|
|
17
|
+
function countEvidenceProblem(record) {
|
|
18
|
+
const counts = [record.fired.total,
|
|
19
|
+
...Object.values(record.fired.byModel),
|
|
21
20
|
...Object.values(record.fired.byOutcome)];
|
|
22
21
|
if (counts.some(n => !validCount(n)) || learning_usage_store_1.FIRING_OUTCOMES.some(outcome => !validCount(record.fired.byOutcome[outcome]))
|
|
23
|
-
|| record.offered.aboveThreshold > record.offered.total
|
|
24
22
|
|| Object.values(record.fired.byOutcome).reduce((a, b) => a + b, 0) !== record.fired.total) {
|
|
25
23
|
return 'Usage counters are inconsistent.';
|
|
26
24
|
}
|
|
27
|
-
|
|
28
|
-
if (!offersEraStart && record.offered.total > 0 && record.fired.total > record.offered.total) {
|
|
29
|
-
return 'Excess uses lack provenance establishing the offer-recording era.';
|
|
30
|
-
}
|
|
31
|
-
return violation ? violation.detail : null;
|
|
25
|
+
return null;
|
|
32
26
|
}
|
|
33
27
|
/** Read-time exclusions follow the actual record, never ambient UI or model state. */
|
|
34
28
|
function usageEvidenceExclusions(store) {
|
|
@@ -37,7 +31,7 @@ function usageEvidenceExclusions(store) {
|
|
|
37
31
|
excluded.set(key, 'Unreadable usage record.');
|
|
38
32
|
const byTitle = new Map();
|
|
39
33
|
for (const record of Object.values(store.entries)) {
|
|
40
|
-
const problem = countEvidenceProblem(record
|
|
34
|
+
const problem = countEvidenceProblem(record);
|
|
41
35
|
if (store.evidence?.status === 'unavailable')
|
|
42
36
|
excluded.set(record.key, 'Usage history is unavailable.');
|
|
43
37
|
else if (problem)
|
|
@@ -7,7 +7,7 @@ exports.resolveCurrentModel = resolveCurrentModel;
|
|
|
7
7
|
* Issue #1103 — projecting the usage record into the shapes its readers need.
|
|
8
8
|
*
|
|
9
9
|
* The store owns writing and persistence; this owns turning what is stored into
|
|
10
|
-
* what a
|
|
10
|
+
* what a human surface or report asks for. Split out because those are two
|
|
11
11
|
* jobs with different reasons to change: the record's schema changes when the
|
|
12
12
|
* privacy or retention contract changes, and these projections change when a
|
|
13
13
|
* consumer needs a different view.
|
|
@@ -18,13 +18,11 @@ const learning_usage_store_1 = require("./learning-usage-store");
|
|
|
18
18
|
const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
|
|
19
19
|
function toLookupValue(record) {
|
|
20
20
|
return {
|
|
21
|
+
historical: true, countingStatus: 'deferred', lastObservedAt: record.fired.lastAttestedAt && Number.isFinite(Date.parse(record.fired.lastAttestedAt)) ? record.fired.lastAttestedAt : null,
|
|
21
22
|
key: record.key,
|
|
22
|
-
offered: record.offered.total,
|
|
23
|
-
offeredAboveThreshold: record.offered.aboveThreshold,
|
|
24
23
|
fired: record.fired.byOutcome.prevented + record.fired.byOutcome.applied,
|
|
25
24
|
firedTotalAttestations: record.fired.total,
|
|
26
25
|
lastFired: record.fired.lastFiredAt,
|
|
27
|
-
lastOffered: record.offered.lastOfferedAt,
|
|
28
26
|
firedUnder: (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record) ? undefined : Object.keys(record.fired.byModel),
|
|
29
27
|
byOutcome: record.fired.byOutcome,
|
|
30
28
|
log: record.fired.log,
|
|
@@ -32,14 +30,14 @@ function toLookupValue(record) {
|
|
|
32
30
|
}
|
|
33
31
|
/**
|
|
34
32
|
* A lookup keyed by every identifier that resolves to a record: the entry id, and
|
|
35
|
-
* every title the entry has been known by. That
|
|
33
|
+
* every title the entry has been known by. That lets a historical surface look up an
|
|
36
34
|
* entry by whichever handle it has.
|
|
37
35
|
*/
|
|
38
36
|
function buildUsageLookup(store) {
|
|
39
37
|
const lookup = new Map();
|
|
40
38
|
const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
|
|
41
39
|
for (const record of Object.values(store.entries)) {
|
|
42
|
-
if (excluded.has(record.key))
|
|
40
|
+
if (excluded.has(record.key) || record.fired.total === 0)
|
|
43
41
|
continue;
|
|
44
42
|
const value = toLookupValue(record);
|
|
45
43
|
lookup.set(record.key, value);
|
|
@@ -66,22 +64,7 @@ function lookupUsage(lookup, id, fileType, title) {
|
|
|
66
64
|
return resolved;
|
|
67
65
|
return identity.source === 'id' ? lookup.get((0, learning_usage_store_1.titleUsageKey)(fileType, title)) : undefined;
|
|
68
66
|
}
|
|
69
|
-
/**
|
|
70
|
-
* Issue #1752 C1 — "the current model" comes from the caller, never from the store.
|
|
71
|
-
*
|
|
72
|
-
* This used to return the model of the globally most-recent offer. The store is
|
|
73
|
-
* shared by concurrent sessions from two machines and two agents, so that reports
|
|
74
|
-
* whichever agent wrote last. On the sleep-on-learnings run of 2026-09-18 it
|
|
75
|
-
* answered `gpt-5` while the session was Claude Opus 5, and since retirement
|
|
76
|
-
* candidacy is computed per model, every per-model conclusion in that run was
|
|
77
|
-
* computed against a model that was not running.
|
|
78
|
-
*
|
|
79
|
-
* Resolution order is the explicit flag, then `FRAIM_ACTIVE_MODEL`, then null.
|
|
80
|
-
* That follows the existing `FRAIM_ACTIVE_USER_EMAIL` convention, which is
|
|
81
|
-
* read-only and operator-set, so it introduces no new configuration mechanism.
|
|
82
|
-
* Null is reported as `unknown` and suppresses every per-model conclusion rather
|
|
83
|
-
* than being computed against a null bucket.
|
|
84
|
-
*/
|
|
67
|
+
/** Model identity comes from the caller or explicit environment, never historical observations. */
|
|
85
68
|
function resolveCurrentModel(explicit) {
|
|
86
69
|
return (0, learning_usage_store_1.normalizeModelName)(explicit) ?? (0, learning_usage_store_1.normalizeModelName)(process.env.FRAIM_ACTIVE_MODEL);
|
|
87
70
|
}
|