@goodbones/campaigns 0.1.1-beta.1 → 0.1.1-beta.3
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/build/dts/core/campaign-state.d.ts +3 -1
- package/build/dts/core/campaign-state.d.ts.map +1 -1
- package/build/dts/core/campaigns.d.ts +46 -2
- package/build/dts/core/campaigns.d.ts.map +1 -1
- package/build/dts/core/ledger.d.ts +58 -2
- package/build/dts/core/ledger.d.ts.map +1 -1
- package/build/dts/domain/config.d.ts +204 -2
- package/build/dts/domain/config.d.ts.map +1 -1
- package/build/dts/host/campaigns.d.ts +34 -0
- package/build/dts/host/campaigns.d.ts.map +1 -1
- package/build/dts/host/measure-command.d.ts +3 -0
- package/build/dts/host/measure-command.d.ts.map +1 -0
- package/build/dts/index.d.ts +5 -4
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/load/extension.d.ts +2 -1
- package/build/dts/load/extension.d.ts.map +1 -1
- package/build/dts/manifest/lower.d.ts.map +1 -1
- package/build/dts/manifest/spec.d.ts +209 -3
- package/build/dts/manifest/spec.d.ts.map +1 -1
- package/build/dts/ports/campaign-measure.d.ts +3 -0
- package/build/dts/ports/campaign-measure.d.ts.map +1 -0
- package/build/esm/core/campaign-state.js +35 -2
- package/build/esm/core/campaign-state.js.map +1 -1
- package/build/esm/core/campaigns.js +177 -2
- package/build/esm/core/campaigns.js.map +1 -1
- package/build/esm/core/ledger.js +215 -0
- package/build/esm/core/ledger.js.map +1 -1
- package/build/esm/domain/config.js +40 -4
- package/build/esm/domain/config.js.map +1 -1
- package/build/esm/host/campaigns.js +421 -28
- package/build/esm/host/campaigns.js.map +1 -1
- package/build/esm/host/measure-command.js +35 -0
- package/build/esm/host/measure-command.js.map +1 -0
- package/build/esm/index.js +2 -2
- package/build/esm/index.js.map +1 -1
- package/build/esm/load/extension.js +57 -13
- package/build/esm/load/extension.js.map +1 -1
- package/build/esm/manifest/lower.js +68 -1
- package/build/esm/manifest/lower.js.map +1 -1
- package/build/esm/manifest/spec.js +85 -5
- package/build/esm/manifest/spec.js.map +1 -1
- package/build/esm/ports/campaign-measure.js +2 -0
- package/build/esm/ports/campaign-measure.js.map +1 -0
- package/package.json +3 -3
- package/src/core/campaign-state.ts +49 -2
- package/src/core/campaigns.ts +232 -3
- package/src/core/ledger.ts +264 -2
- package/src/domain/config.ts +46 -4
- package/src/host/campaigns.ts +571 -33
- package/src/host/measure-command.ts +36 -0
- package/src/index.ts +31 -0
- package/src/load/extension.ts +70 -6
- package/src/manifest/lower.ts +80 -1
- package/src/manifest/spec.ts +100 -5
- package/src/ports/campaign-measure.ts +11 -0
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { compileExportRules, compileImportRules, compileMemberRules, compileStructure, compileSurfaceRules, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, exportRulesSelecting, globToRegExp, listSourceFiles, listWorkspaceProjects, memberRulesSelecting, rulesSelecting, surfaceRulesSelecting, } from "@goodbones/core";
|
|
4
|
+
import { breaches, compileExportRules, compileImportRules, compileMemberRules, compileStructure, compileSurfaceRules, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, exportRulesSelecting, globToRegExp, listSourceFiles, listWorkspaceProjects, memberRulesSelecting, rulesSelecting, surfaceRulesSelecting, } from "@goodbones/core";
|
|
5
5
|
import * as Result from "effect/Result";
|
|
6
6
|
import { evaluateCampaign, hitsInWindow, towardNextOf, } from "../core/campaign-state.js";
|
|
7
|
-
import { detectorOf, needsSyntax, } from "../core/campaigns.js";
|
|
8
|
-
import { attestedRecord, clearedSector, concededSector, EMPTY_LEDGER, EMPTY_SECTOR_RECORD, isComplete, isStalled, lastClearedOf, ledgerArithmeticHolds, ledgerPathOf, notedRecord, planDiffOf, planOf, planPathOf, progressOf, reachedRecord, rebaselinedSector, reconcileSector, sectorArithmeticHolds, sectorClockOf, sectorRecordPathOf, serializeLedger, serializePlanRecord, serializeSectorRecord, } from "../core/ledger.js";
|
|
7
|
+
import { detectorOf, distanceToTarget, measureDetectorsOf, needsSyntax, } from "../core/campaigns.js";
|
|
8
|
+
import { attestedRecord, clearedMeasure, clearedSector, concededMeasure, concededSector, EMPTY_LEDGER, EMPTY_MEASURE_LEDGER, EMPTY_SECTOR_RECORD, isComplete, isStalled, lastClearedOf, lastImprovedOf, ledgerArithmeticHolds, ledgerPathOf, measureLedgerArithmeticHolds, measureProgressOf, measureStandingOf, notedRecord, planDiffOf, planOf, planPathOf, progressOf, reachedRecord, rebaselinedSector, reconcileSector, recordedOf, sectorArithmeticHolds, sectorClockOf, sectorRecordPathOf, serializeLedger, serializeMeasureLedger, serializePlanRecord, serializeSectorRecord, } from "../core/ledger.js";
|
|
9
9
|
import { compareResidue, isDefinedPhase, isOpenPhase, onTouchOf, worsened, } from "../core/phases.js";
|
|
10
10
|
import { LEGACY_SECTOR, parseSectorMarker } from "../core/sectors.js";
|
|
11
11
|
import { campaignsOf, ledgerKeyOf } from "../load/extension.js";
|
|
12
12
|
import { lowerEndState } from "../manifest/lower.js";
|
|
13
13
|
import { commitOf, commitsTouching, distanceToHunks, gitEnv, materializeTree, readDiff, textAt, } from "./diff.js";
|
|
14
|
+
import { commandValues } from "./measure-command.js";
|
|
14
15
|
// The files a campaign sees: those in its scope with an extension a pack
|
|
15
16
|
// walks, or one the campaign's scope widens the walk to.
|
|
16
17
|
const filesFor = (policy, rule, files) => {
|
|
@@ -123,11 +124,12 @@ export const evaluateCampaigns = (policy, roots, files, readers = readersOf(poli
|
|
|
123
124
|
const projects = campaignsOf(policy).campaignRules.some((rule) => rule.perimeter?.kind === "nx")
|
|
124
125
|
? listWorkspaceProjects(policy.repoRoot, roots)
|
|
125
126
|
: [];
|
|
127
|
+
const commandValueOf = commandValues(policy.repoRoot);
|
|
126
128
|
return campaignsOf(policy).campaignRules.map((rule) => {
|
|
127
129
|
const detectors = [
|
|
128
130
|
...rule.objectives.flatMap((one) => {
|
|
129
131
|
const detect = detectorOf(one);
|
|
130
|
-
return detect === null ? [] : [detect];
|
|
132
|
+
return [...(detect === null ? [] : [detect]), ...measureDetectorsOf(one)];
|
|
131
133
|
}),
|
|
132
134
|
...(rule.perimeter?.kind === "match" ? [rule.perimeter.detect] : []),
|
|
133
135
|
];
|
|
@@ -152,6 +154,7 @@ export const evaluateCampaigns = (policy, roots, files, readers = readersOf(poli
|
|
|
152
154
|
globToRegExp,
|
|
153
155
|
projects,
|
|
154
156
|
recordOf: (sector) => campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector)),
|
|
157
|
+
commandValueOf,
|
|
155
158
|
};
|
|
156
159
|
if (!rule.objectives.some((one) => one.endState !== null))
|
|
157
160
|
return evaluateCampaign(rule, input);
|
|
@@ -177,6 +180,9 @@ export const evaluateCampaigns = (policy, roots, files, readers = readersOf(poli
|
|
|
177
180
|
});
|
|
178
181
|
};
|
|
179
182
|
const ledgerOf = (policy, rule, objective) => campaignsOf(policy).ledgers.get(ledgerKeyOf(rule.id, objective.id));
|
|
183
|
+
const measureLedgerOf = (policy, rule, objective) => campaignsOf(policy).measureLedgers.get(ledgerKeyOf(rule.id, objective.id));
|
|
184
|
+
// How a scalar reads in a line: its value, and against what.
|
|
185
|
+
const describeValue = (value) => Number.isNaN(value) ? "no number" : String(value);
|
|
180
186
|
const recordOf = (policy, rule, sector) => campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector));
|
|
181
187
|
const phaseIdOf = (rule, phase) => rule.phases[phase]?.id ?? null;
|
|
182
188
|
const entriesOf = (hits, objective, sector) => hits
|
|
@@ -189,7 +195,113 @@ export const campaignReportsOf = (policy, evaluations) => evaluations.map((evalu
|
|
|
189
195
|
const { rule } = evaluation;
|
|
190
196
|
const counted = hitsInWindow(evaluation);
|
|
191
197
|
let missingLedger = false;
|
|
198
|
+
const unmeasured = [];
|
|
199
|
+
const scalarNew = [];
|
|
200
|
+
const scalarStale = [];
|
|
201
|
+
// A scalar objective, reconciled per sector against its ledger. A value
|
|
202
|
+
// worse than the record past the tolerance is growth `concede` records;
|
|
203
|
+
// better past it, progress `clear` records — each a line of the same
|
|
204
|
+
// `new` and `stale` lists a holdout lands in, so `check` fails on them
|
|
205
|
+
// for the same reasons and says the same next step.
|
|
206
|
+
const scalarReport = (objective) => {
|
|
207
|
+
const ledger = measureLedgerOf(policy, rule, objective);
|
|
208
|
+
const sectors = [];
|
|
209
|
+
let value = 0;
|
|
210
|
+
let recorded = 0;
|
|
211
|
+
let measuredAll = true;
|
|
212
|
+
let recordedAll = true;
|
|
213
|
+
for (const name of sectorNames(evaluation)) {
|
|
214
|
+
const state = evaluation.sectors.get(name);
|
|
215
|
+
if (state === undefined)
|
|
216
|
+
continue;
|
|
217
|
+
const measured = state.values[objective.id] ?? Number.NaN;
|
|
218
|
+
const own = ledger?.sectors[name];
|
|
219
|
+
if (!state.inWindow.some((one) => one.id === objective.id)) {
|
|
220
|
+
// Past the window: a record still open is closed by `clear`.
|
|
221
|
+
if (own?.closed === null) {
|
|
222
|
+
scalarStale.push({
|
|
223
|
+
objective: objective.id,
|
|
224
|
+
sector: name,
|
|
225
|
+
entry: `left the window at ${describeValue(measured)}`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (Number.isNaN(measured)) {
|
|
231
|
+
measuredAll = false;
|
|
232
|
+
unmeasured.push({ objective: objective.id, sector: name });
|
|
233
|
+
}
|
|
234
|
+
else
|
|
235
|
+
value += measured;
|
|
236
|
+
if (ledger === undefined || own === undefined || own.closed !== null) {
|
|
237
|
+
missingLedger = true;
|
|
238
|
+
recordedAll = false;
|
|
239
|
+
sectors.push({
|
|
240
|
+
sector: name,
|
|
241
|
+
count: 0,
|
|
242
|
+
new: [],
|
|
243
|
+
stale: [],
|
|
244
|
+
drifted: 0,
|
|
245
|
+
unrecorded: true,
|
|
246
|
+
arithmetic: true,
|
|
247
|
+
measure: {
|
|
248
|
+
value: Number.isNaN(measured) ? null : measured,
|
|
249
|
+
recorded: null,
|
|
250
|
+
standing: Number.isNaN(measured) ? "unmeasured" : "unrecorded",
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
recorded += own.recorded;
|
|
256
|
+
const standing = Number.isNaN(measured)
|
|
257
|
+
? "unmeasured"
|
|
258
|
+
: measureStandingOf(ledger, name, measured, objective.tolerance);
|
|
259
|
+
const line = `measured ${describeValue(measured)}, recorded ${String(own.recorded)}`;
|
|
260
|
+
if (standing === "breached") {
|
|
261
|
+
scalarNew.push({ objective: objective.id, sector: name, entry: line });
|
|
262
|
+
}
|
|
263
|
+
if (standing === "surpassed") {
|
|
264
|
+
scalarStale.push({ objective: objective.id, sector: name, entry: line });
|
|
265
|
+
}
|
|
266
|
+
sectors.push({
|
|
267
|
+
sector: name,
|
|
268
|
+
count: 0,
|
|
269
|
+
new: standing === "breached" ? [line] : [],
|
|
270
|
+
stale: standing === "surpassed" ? [line] : [],
|
|
271
|
+
drifted: 0,
|
|
272
|
+
unrecorded: false,
|
|
273
|
+
arithmetic: measureLedgerArithmeticHolds(ledger),
|
|
274
|
+
measure: {
|
|
275
|
+
value: Number.isNaN(measured) ? null : measured,
|
|
276
|
+
recorded: own.recorded,
|
|
277
|
+
standing,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// Sectors the ledger records that the code no longer births: closed
|
|
282
|
+
// on the next `clear`, stale until then.
|
|
283
|
+
for (const [name, own] of Object.entries(ledger?.sectors ?? {})) {
|
|
284
|
+
if (evaluation.sectors.has(name) || own.closed !== null)
|
|
285
|
+
continue;
|
|
286
|
+
scalarStale.push({ objective: objective.id, sector: name, entry: "no longer a sector" });
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
id: objective.id,
|
|
290
|
+
count: 0,
|
|
291
|
+
ledgered: ledger !== undefined,
|
|
292
|
+
sectors,
|
|
293
|
+
measure: {
|
|
294
|
+
direction: objective.direction,
|
|
295
|
+
value: measuredAll ? value : null,
|
|
296
|
+
recorded: recordedAll ? recorded : null,
|
|
297
|
+
target: objective.target,
|
|
298
|
+
tolerance: objective.tolerance,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
};
|
|
192
302
|
const objectives = rule.objectives.map((objective) => {
|
|
303
|
+
if (objective.measure !== null)
|
|
304
|
+
return scalarReport(objective);
|
|
193
305
|
const ledger = ledgerOf(policy, rule, objective);
|
|
194
306
|
const sectors = [];
|
|
195
307
|
for (const name of sectorNames(evaluation)) {
|
|
@@ -271,6 +383,19 @@ export const campaignReportsOf = (policy, evaluations) => evaluations.map((evalu
|
|
|
271
383
|
const ledger = ledgerOf(policy, rule, objective);
|
|
272
384
|
return ledger === undefined ? [] : [ledger];
|
|
273
385
|
});
|
|
386
|
+
const measureLedgers = rule.objectives.flatMap((objective) => {
|
|
387
|
+
const ledger = measureLedgerOf(policy, rule, objective);
|
|
388
|
+
return ledger === undefined ? [] : [{ objective, ledger }];
|
|
389
|
+
});
|
|
390
|
+
// A targeted scalar is met in every sector in window when its distance
|
|
391
|
+
// is 0 there; a standing one never holds a campaign open.
|
|
392
|
+
const scalarsMet = rule.objectives
|
|
393
|
+
.filter((objective) => objective.measure !== null && objective.target !== null)
|
|
394
|
+
.every((objective) => [...evaluation.sectors.values()].every((state) => !state.inWindow.some((one) => one.id === objective.id) ||
|
|
395
|
+
(state.counts[objective.id] ?? 0) === 0));
|
|
396
|
+
const scalarsOpen = measureLedgers.filter(({ objective }) => objective.target !== null &&
|
|
397
|
+
[...evaluation.sectors.values()].some((state) => state.inWindow.some((one) => one.id === objective.id) &&
|
|
398
|
+
(state.counts[objective.id] ?? 0) > 0));
|
|
274
399
|
const sectorClock = [...evaluation.sectors.keys()]
|
|
275
400
|
.map((name) => recordOf(policy, rule, name))
|
|
276
401
|
.filter((one) => one !== undefined)
|
|
@@ -280,14 +405,23 @@ export const campaignReportsOf = (policy, evaluations) => evaluations.map((evalu
|
|
|
280
405
|
return {
|
|
281
406
|
id: rule.id,
|
|
282
407
|
count,
|
|
283
|
-
new:
|
|
284
|
-
|
|
408
|
+
new: [
|
|
409
|
+
...flat((one) => (one.measure === undefined ? one.new : [])),
|
|
410
|
+
...scalarNew,
|
|
411
|
+
],
|
|
412
|
+
stale: [
|
|
413
|
+
...flat((one) => (one.measure === undefined ? one.stale : [])),
|
|
414
|
+
...scalarStale,
|
|
415
|
+
],
|
|
285
416
|
drifted: objectives.reduce((sum, one) => sum + one.sectors.reduce((inner, two) => inner + two.drifted, 0), 0),
|
|
286
417
|
missingLedger,
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
418
|
+
unmeasured,
|
|
419
|
+
arithmetic: ledgers.every(ledgerArithmeticHolds) &&
|
|
420
|
+
measureLedgers.every(({ ledger }) => measureLedgerArithmeticHolds(ledger)),
|
|
421
|
+
complete: count === 0 && ledgers.every(isComplete) && scalarsMet && unmeasured.length === 0,
|
|
422
|
+
stalled: (ledgers.length > 0 &&
|
|
423
|
+
ledgers.some((ledger) => isStalled(rule, ledger, policy.now, sectorClock))) ||
|
|
424
|
+
scalarsOpen.some(({ ledger }) => isMeasureStalled(rule, ledger, policy.now, sectorClock)),
|
|
291
425
|
onComplete: rule.onComplete,
|
|
292
426
|
objectives,
|
|
293
427
|
sectors: [...evaluation.sectors.values()].map((state) => ({
|
|
@@ -301,6 +435,17 @@ export const campaignReportsOf = (policy, evaluations) => evaluations.map((evalu
|
|
|
301
435
|
plan: planDiffOf(rule, campaignsOf(policy).plans.get(rule.id)),
|
|
302
436
|
};
|
|
303
437
|
});
|
|
438
|
+
// A scalar short of its target is stalled when its record has not improved,
|
|
439
|
+
// and no sector has been attested or noted, within the campaign's
|
|
440
|
+
// `staleAfter`.
|
|
441
|
+
const isMeasureStalled = (rule, ledger, now, sectorClock) => {
|
|
442
|
+
if (rule.staleAfter === null)
|
|
443
|
+
return false;
|
|
444
|
+
const last = [lastImprovedOf(ledger), sectorClock]
|
|
445
|
+
.filter((one) => one !== null)
|
|
446
|
+
.reduce((a, b) => (a > b ? a : b), ledger.created);
|
|
447
|
+
return now - Date.parse(last) > rule.staleAfter;
|
|
448
|
+
};
|
|
304
449
|
// Which hits in window are carried by a ledger, exactly or by anchor.
|
|
305
450
|
export const ledgeredFilter = (policy, evaluations) => {
|
|
306
451
|
const carried = new Set();
|
|
@@ -330,6 +475,9 @@ export const campaignFailuresOf = (campaigns) => [
|
|
|
330
475
|
...campaigns
|
|
331
476
|
.filter((one) => one.plan.unreceipted.length > 0)
|
|
332
477
|
.map((one) => `campaign ${one.id}: a defined phase changed without a concession`),
|
|
478
|
+
...campaigns
|
|
479
|
+
.filter((one) => one.unmeasured.length > 0)
|
|
480
|
+
.map((one) => `campaign ${one.id}: a scalar objective measured no number`),
|
|
333
481
|
...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
|
|
334
482
|
...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
|
|
335
483
|
...campaigns.filter((one) => one.missingLedger).map((one) => `campaign ${one.id} has no ledger`),
|
|
@@ -358,10 +506,16 @@ export const renderCampaignReports = (reports, hits) => reports.flatMap((campaig
|
|
|
358
506
|
}
|
|
359
507
|
if (campaign.missingLedger) {
|
|
360
508
|
const unrecorded = campaign.objectives.flatMap((objective) => objective.sectors
|
|
361
|
-
.filter((one) => one.unrecorded &&
|
|
362
|
-
|
|
509
|
+
.filter((one) => one.unrecorded &&
|
|
510
|
+
(one.count > 0 || objective.ledgered || one.measure !== undefined))
|
|
511
|
+
.map((one) => ` ${objective.id} · ${one.sector} (${one.measure === undefined
|
|
512
|
+
? count(one.count, "hit")
|
|
513
|
+
: `measures ${one.measure.value === null ? "no number" : String(one.measure.value)}`})`));
|
|
363
514
|
say("", `campaign ${campaign.id}: ${count(unrecorded.length, "sector")} in an objective's window that no ledger has seen. Record them before they count as growth:`, ...unrecorded, "", ` architecture objectives clear ${campaign.id}`);
|
|
364
515
|
}
|
|
516
|
+
if (campaign.unmeasured.length > 0) {
|
|
517
|
+
say("", `campaign ${campaign.id}: ${count(campaign.unmeasured.length, "sector")} where a scalar objective measured no number — a function that answered something other than a number of zero or more, or a command that did not run or printed none:`, ...campaign.unmeasured.map((one) => ` ${one.objective} · ${one.sector}`));
|
|
518
|
+
}
|
|
365
519
|
if (campaign.new.length > 0 && !campaign.missingLedger) {
|
|
366
520
|
say("", `campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`, ...campaign.new.flatMap((one) => {
|
|
367
521
|
const hit = hits.find((two) => two.objective === one.objective &&
|
|
@@ -377,7 +531,7 @@ export const renderCampaignReports = (reports, hits) => reports.flatMap((campaig
|
|
|
377
531
|
say("", `campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire, or fire past their window. The code was fixed, or the sector moved on; clear them:`, ...campaign.stale.map((one) => at(one.objective, one.sector, one.entry)), "", ` architecture objectives clear ${campaign.id}`);
|
|
378
532
|
}
|
|
379
533
|
if (!campaign.arithmetic) {
|
|
380
|
-
say("", `campaign ${campaign.id}: a ledger does not add up (holdouts ≠ initial + conceded − cleared − closed).
|
|
534
|
+
say("", `campaign ${campaign.id}: a ledger does not add up (holdouts ≠ initial + conceded − cleared − closed; for a scalar, recorded ≠ initial + risen − improved). The ledger was edited by hand; restore it, or record the change with \`objectives concede\`.`);
|
|
381
535
|
}
|
|
382
536
|
const complete = campaign.complete && !campaign.missingLedger;
|
|
383
537
|
if (complete && campaign.onComplete === "remove") {
|
|
@@ -405,10 +559,39 @@ export const snapshotCampaignsOf = (policy, evaluations) => campaignReportsOf(po
|
|
|
405
559
|
throw new Error("report without evaluation");
|
|
406
560
|
const { rule } = evaluation;
|
|
407
561
|
const ledgers = rule.objectives.map((objective) => ledgerOf(policy, rule, objective));
|
|
562
|
+
const scalar = new Set(rule.objectives.filter((one) => one.measure !== null).map((one) => one.id));
|
|
408
563
|
const objectives = rule.objectives.map((objective, j) => {
|
|
409
564
|
const ledger = ledgers[j];
|
|
410
565
|
const own = report.objectives[j];
|
|
411
566
|
const phase = rule.phases.find((one) => one.objectives.includes(objective.id))?.id ?? null;
|
|
567
|
+
if (objective.measure !== null) {
|
|
568
|
+
// A scalar: the holdout counts are all 0, and the number is under
|
|
569
|
+
// `measure`. Met when every sector in window is at its target.
|
|
570
|
+
const measured = measureLedgerOf(policy, rule, objective);
|
|
571
|
+
const met = objective.target !== null &&
|
|
572
|
+
(own?.sectors ?? []).every((one) => (evaluation.sectors.get(one.sector)?.counts[objective.id] ?? 1) === 0);
|
|
573
|
+
return {
|
|
574
|
+
id: objective.id,
|
|
575
|
+
phase,
|
|
576
|
+
initial: 0,
|
|
577
|
+
allowed: 0,
|
|
578
|
+
count: 0,
|
|
579
|
+
cleared: 0,
|
|
580
|
+
closed: 0,
|
|
581
|
+
progress: measured === undefined ? 0 : measureProgressOf(measured, objective.target),
|
|
582
|
+
lastCleared: measured === undefined ? null : lastImprovedOf(measured),
|
|
583
|
+
concessions: measured?.concessions.length ?? 0,
|
|
584
|
+
complete: met,
|
|
585
|
+
ledgered: measured !== undefined,
|
|
586
|
+
measure: {
|
|
587
|
+
direction: objective.direction,
|
|
588
|
+
value: own?.measure?.value ?? 0,
|
|
589
|
+
recorded: own?.measure?.recorded ?? (measured === undefined ? 0 : recordedOf(measured)),
|
|
590
|
+
target: objective.target,
|
|
591
|
+
tolerance: objective.tolerance,
|
|
592
|
+
},
|
|
593
|
+
};
|
|
594
|
+
}
|
|
412
595
|
if (ledger === undefined) {
|
|
413
596
|
return {
|
|
414
597
|
id: objective.id,
|
|
@@ -481,13 +664,19 @@ export const snapshotCampaignsOf = (policy, evaluations) => campaignReportsOf(po
|
|
|
481
664
|
}),
|
|
482
665
|
legacy: {
|
|
483
666
|
files: evaluation.index.legacy.length,
|
|
484
|
-
holdouts: legacy === undefined
|
|
667
|
+
holdouts: legacy === undefined
|
|
668
|
+
? 0
|
|
669
|
+
: Object.entries(legacy.residue)
|
|
670
|
+
.filter(([id]) => !scalar.has(id))
|
|
671
|
+
.reduce((a, [, n]) => a + n, 0),
|
|
485
672
|
},
|
|
486
673
|
plan: report.plan,
|
|
487
674
|
stalled: report.stalled,
|
|
488
675
|
complete: report.complete,
|
|
489
676
|
onComplete: rule.onComplete,
|
|
490
|
-
ledgered:
|
|
677
|
+
ledgered: rule.objectives.every((objective, j) => objective.measure === null
|
|
678
|
+
? ledgers[j] !== undefined
|
|
679
|
+
: measureLedgerOf(policy, rule, objective) !== undefined),
|
|
491
680
|
};
|
|
492
681
|
});
|
|
493
682
|
const percent = (fraction) => `${String(Math.round(fraction * 100))}%`;
|
|
@@ -515,10 +704,16 @@ export const renderCampaignRows = (campaigns) => {
|
|
|
515
704
|
.join(" → ")}` +
|
|
516
705
|
(one.legacy.files > 0 ? ` · legacy ${count(one.legacy.files, "file")}` : ""),
|
|
517
706
|
]),
|
|
518
|
-
...one.objectives.map((objective) =>
|
|
519
|
-
` ${String(objective.
|
|
520
|
-
|
|
521
|
-
|
|
707
|
+
...one.objectives.map((objective) => objective.measure !== undefined
|
|
708
|
+
? ` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} measures ${String(objective.measure.value)}, held to ${String(objective.measure.recorded)}` +
|
|
709
|
+
(objective.measure.target === null
|
|
710
|
+
? ""
|
|
711
|
+
: `, target ${String(objective.measure.target)}`) +
|
|
712
|
+
(objective.ledgered ? "" : " no ledger")
|
|
713
|
+
: ` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} ${String(objective.count).padStart(5)} left` +
|
|
714
|
+
` ${String(objective.cleared)} cleared ${String(objective.allowed)} conceded` +
|
|
715
|
+
(objective.closed > 0 ? ` ${String(objective.closed)} closed` : "") +
|
|
716
|
+
(objective.ledgered ? "" : " no ledger")),
|
|
522
717
|
];
|
|
523
718
|
});
|
|
524
719
|
};
|
|
@@ -550,6 +745,80 @@ export const authorOf = (given) => {
|
|
|
550
745
|
const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
|
|
551
746
|
return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
|
|
552
747
|
};
|
|
748
|
+
// `clear` for a scalar objective: a sector entering the window is recorded
|
|
749
|
+
// at its value; one inside it whose value improved past the tolerance is
|
|
750
|
+
// held to that value from now; one past it, or no longer born, is closed;
|
|
751
|
+
// and a receipted phase change re-baselines the sectors in window to what
|
|
752
|
+
// they measure, with a concession. A rise is left where it is.
|
|
753
|
+
const clearMeasure = (policy, evaluation, objective, receipted, by) => {
|
|
754
|
+
const { rule } = evaluation;
|
|
755
|
+
const existing = measureLedgerOf(policy, rule, objective);
|
|
756
|
+
const before = existing ?? EMPTY_MEASURE_LEDGER(rule.id, objective.id, objective.direction, policy.now);
|
|
757
|
+
let ledger = before;
|
|
758
|
+
const entered = [];
|
|
759
|
+
const rebaselined = [];
|
|
760
|
+
const improved = [];
|
|
761
|
+
let closed = 0;
|
|
762
|
+
for (const [name, state] of evaluation.sectors) {
|
|
763
|
+
const value = state.values[objective.id] ?? Number.NaN;
|
|
764
|
+
const own = ledger.sectors[name];
|
|
765
|
+
if (!state.inWindow.some((one) => one.id === objective.id)) {
|
|
766
|
+
const next = clearedMeasure(ledger, name, value, objective.tolerance, policy.now, "outside");
|
|
767
|
+
if (next !== ledger)
|
|
768
|
+
closed += 1;
|
|
769
|
+
ledger = next;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (own === undefined || own.closed !== null) {
|
|
773
|
+
const next = clearedMeasure(ledger, name, value, objective.tolerance, policy.now, "inside", by);
|
|
774
|
+
if (next !== ledger)
|
|
775
|
+
entered.push(name);
|
|
776
|
+
ledger = next;
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
if (receipted !== null) {
|
|
780
|
+
const next = concededMeasure(ledger, name, value, {
|
|
781
|
+
at: policy.now,
|
|
782
|
+
by,
|
|
783
|
+
reason: `phase ${receipted.id} changed: ${receipted.concessions.at(-1)?.reason ?? ""}`,
|
|
784
|
+
});
|
|
785
|
+
if (next !== ledger)
|
|
786
|
+
rebaselined.push(name);
|
|
787
|
+
ledger = next;
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
const next = clearedMeasure(ledger, name, value, objective.tolerance, policy.now, "inside");
|
|
791
|
+
if (next !== ledger) {
|
|
792
|
+
improved.push({
|
|
793
|
+
sector: name,
|
|
794
|
+
from: own.recorded,
|
|
795
|
+
to: next.sectors[name]?.recorded ?? own.recorded,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
ledger = next;
|
|
799
|
+
}
|
|
800
|
+
// Sectors the code no longer births are past every window.
|
|
801
|
+
for (const [name, own] of Object.entries(ledger.sectors)) {
|
|
802
|
+
if (evaluation.sectors.has(name) || own.closed !== null)
|
|
803
|
+
continue;
|
|
804
|
+
ledger = clearedMeasure(ledger, name, own.recorded, objective.tolerance, policy.now, "outside");
|
|
805
|
+
closed += 1;
|
|
806
|
+
}
|
|
807
|
+
if (ledger !== before || existing === undefined) {
|
|
808
|
+
writeJson(policy.repoRoot, ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id), serializeMeasureLedger(ledger));
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
campaign: rule.id,
|
|
812
|
+
objective: objective.id,
|
|
813
|
+
cleared: 0,
|
|
814
|
+
rewritten: 0,
|
|
815
|
+
closed,
|
|
816
|
+
entered,
|
|
817
|
+
rebaselined,
|
|
818
|
+
left: 0,
|
|
819
|
+
measure: { improved, recorded: recordedOf(ledger) },
|
|
820
|
+
};
|
|
821
|
+
};
|
|
553
822
|
// `clear`: the ledger reconciled with the code wherever that is not a
|
|
554
823
|
// regression. Stale holdouts leave and drifted ones are rewritten; a sector
|
|
555
824
|
// newly in an objective's window is recorded with its initial; a sector
|
|
@@ -565,15 +834,19 @@ export const clear = (policy, evaluation, only, by) => {
|
|
|
565
834
|
for (const objective of rule.objectives) {
|
|
566
835
|
if (only !== null && objective.id !== only)
|
|
567
836
|
continue;
|
|
568
|
-
const before = ledgerOf(policy, rule, objective) ?? EMPTY_LEDGER(rule.id, objective.id, policy.now);
|
|
569
|
-
let ledger = before;
|
|
570
|
-
const entered = [];
|
|
571
|
-
const rebaselined = [];
|
|
572
837
|
const phase = rule.phases.find((one) => one.objectives.includes(objective.id));
|
|
573
838
|
// The phase naming the objective, when it changed with a receipt.
|
|
574
839
|
const receipted = phase !== undefined && plan.changed.includes(phase.id) && !plan.unreceipted.includes(phase.id)
|
|
575
840
|
? phase
|
|
576
841
|
: null;
|
|
842
|
+
if (objective.measure !== null) {
|
|
843
|
+
outcomes.push(clearMeasure(policy, evaluation, objective, receipted, by));
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
const before = ledgerOf(policy, rule, objective) ?? EMPTY_LEDGER(rule.id, objective.id, policy.now);
|
|
847
|
+
let ledger = before;
|
|
848
|
+
const entered = [];
|
|
849
|
+
const rebaselined = [];
|
|
577
850
|
let rewritten = 0;
|
|
578
851
|
for (const [name, state] of evaluation.sectors) {
|
|
579
852
|
const inWindow = state.inWindow.some((one) => one.id === objective.id);
|
|
@@ -645,6 +918,12 @@ export const concede = (policy, evaluation, objectiveId, chosen, sector, record)
|
|
|
645
918
|
const objective = rule.objectives.find((one) => one.id === objectiveId);
|
|
646
919
|
if (objective === undefined)
|
|
647
920
|
return Result.fail(`no objective of ${rule.id} is named "${objectiveId}"`);
|
|
921
|
+
if (objective.measure !== null) {
|
|
922
|
+
if (chosen !== null) {
|
|
923
|
+
return Result.fail(`${rule.id}/${objectiveId} is a scalar objective: it has no holdouts to choose among. Narrow with --sector.`);
|
|
924
|
+
}
|
|
925
|
+
return concedeMeasure(policy, evaluation, objective, sector, record);
|
|
926
|
+
}
|
|
648
927
|
let ledger = ledgerOf(policy, rule, objective);
|
|
649
928
|
if (ledger === undefined) {
|
|
650
929
|
return Result.fail(`objective ${rule.id}/${objectiveId} has no ledger yet; run \`objectives clear ${rule.id}\` first.`);
|
|
@@ -684,6 +963,41 @@ export const concede = (policy, evaluation, objectiveId, chosen, sector, record)
|
|
|
684
963
|
left: unrecorded.filter((one) => !wanted.includes(one)),
|
|
685
964
|
});
|
|
686
965
|
};
|
|
966
|
+
// `concede` for a scalar objective: each sector in window whose value is
|
|
967
|
+
// worse than its record past the tolerance is held to its value from now,
|
|
968
|
+
// with a concession naming the reason and the author. A sector the ledger
|
|
969
|
+
// has not recorded is `clear`'s to enter first.
|
|
970
|
+
const concedeMeasure = (policy, evaluation, objective, sector, record) => {
|
|
971
|
+
const { rule } = evaluation;
|
|
972
|
+
let ledger = measureLedgerOf(policy, rule, objective);
|
|
973
|
+
if (ledger === undefined) {
|
|
974
|
+
return Result.fail(`objective ${rule.id}/${objective.id} has no ledger yet; run \`objectives clear ${rule.id}\` first.`);
|
|
975
|
+
}
|
|
976
|
+
const conceded = [];
|
|
977
|
+
const left = [];
|
|
978
|
+
for (const [name, state] of evaluation.sectors) {
|
|
979
|
+
if (!state.inWindow.some((one) => one.id === objective.id))
|
|
980
|
+
continue;
|
|
981
|
+
const value = state.values[objective.id] ?? Number.NaN;
|
|
982
|
+
const standing = Number.isNaN(value)
|
|
983
|
+
? "unmeasured"
|
|
984
|
+
: measureStandingOf(ledger, name, value, objective.tolerance);
|
|
985
|
+
if (standing !== "breached")
|
|
986
|
+
continue;
|
|
987
|
+
const from = ledger.sectors[name]?.recorded ?? value;
|
|
988
|
+
const entry = `${String(from)} → ${String(value)}`;
|
|
989
|
+
if (sector !== null && name !== sector) {
|
|
990
|
+
left.push({ sector: name, entry });
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
ledger = concededMeasure(ledger, name, value, record);
|
|
994
|
+
conceded.push({ sector: name, entry });
|
|
995
|
+
}
|
|
996
|
+
if (conceded.length > 0) {
|
|
997
|
+
writeJson(policy.repoRoot, ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id), serializeMeasureLedger(ledger));
|
|
998
|
+
}
|
|
999
|
+
return Result.succeed({ objective: objective.id, conceded, left });
|
|
1000
|
+
};
|
|
687
1001
|
// `attest`: a step no detector sees is recorded done for a sector — with a
|
|
688
1002
|
// reason and evidence — and only while the sector stands at that phase, so
|
|
689
1003
|
// it cannot be recorded ahead and passed through on arrival.
|
|
@@ -738,6 +1052,11 @@ export const baseSideOf = (evaluations) => evaluations.map((evaluation) => ({
|
|
|
738
1052
|
objective: hit.objective,
|
|
739
1053
|
entry: hit.entry,
|
|
740
1054
|
})),
|
|
1055
|
+
// `NaN` does not survive the JSON cache; `null` is "no number".
|
|
1056
|
+
values: Object.fromEntries([...evaluation.sectors.values()].map((state) => [
|
|
1057
|
+
state.name,
|
|
1058
|
+
Object.fromEntries(Object.entries(state.values).map(([id, value]) => [id, Number.isNaN(value) ? null : value])),
|
|
1059
|
+
])),
|
|
741
1060
|
}));
|
|
742
1061
|
// The nudge for one diff. The "before" side is the ledger, or the base tree
|
|
743
1062
|
// evaluated whole in the exact mode; the "after" side is the tree now.
|
|
@@ -785,8 +1104,38 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
|
|
|
785
1104
|
const open = phase !== undefined && isOpenPhase(phase);
|
|
786
1105
|
const onTouch = onTouchOf(rule, state.phase);
|
|
787
1106
|
// Before: the ledger's count per objective in window, or the base tree's.
|
|
1107
|
+
// A scalar's is its distance to target from the value before: the
|
|
1108
|
+
// base tree's, or the ledger's record.
|
|
788
1109
|
const before = {};
|
|
1110
|
+
const measures = [];
|
|
789
1111
|
for (const objective of state.inWindow) {
|
|
1112
|
+
if (objective.measure !== null) {
|
|
1113
|
+
const recorded = measureLedgerOf(policy, rule, objective)?.sectors[name];
|
|
1114
|
+
const baseValue = baseEvaluation?.values === undefined
|
|
1115
|
+
? undefined
|
|
1116
|
+
: baseEvaluation.values[name]?.[objective.id];
|
|
1117
|
+
const was = baseValue !== undefined
|
|
1118
|
+
? baseValue
|
|
1119
|
+
: recorded === undefined || recorded.closed !== null
|
|
1120
|
+
? null
|
|
1121
|
+
: recorded.recorded;
|
|
1122
|
+
const value = state.values[objective.id] ?? Number.NaN;
|
|
1123
|
+
const now_ = Number.isNaN(value) ? null : value;
|
|
1124
|
+
before[objective.id] =
|
|
1125
|
+
was === null ? (state.counts[objective.id] ?? 0) : distanceToTarget(objective, was);
|
|
1126
|
+
measures.push({
|
|
1127
|
+
objective: objective.id,
|
|
1128
|
+
direction: objective.direction,
|
|
1129
|
+
before: was,
|
|
1130
|
+
after: now_,
|
|
1131
|
+
target: objective.target,
|
|
1132
|
+
tolerance: objective.tolerance,
|
|
1133
|
+
back: was !== null &&
|
|
1134
|
+
(now_ === null ||
|
|
1135
|
+
breaches({ direction: objective.direction, limit: was, tolerance: objective.tolerance }, now_)),
|
|
1136
|
+
});
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
790
1139
|
if (baseEvaluation !== null) {
|
|
791
1140
|
before[objective.id] = baseEvaluation.sectors[name]?.[objective.id] ?? 0;
|
|
792
1141
|
}
|
|
@@ -869,9 +1218,19 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
|
|
|
869
1218
|
if (state_.stale.length > 0)
|
|
870
1219
|
editsHoldout = true;
|
|
871
1220
|
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1221
|
+
// A scalar goes back by its tolerance, not by any rise of its
|
|
1222
|
+
// distance; the holdout dimensions go back by any rise at all. Only
|
|
1223
|
+
// the holdouts are paid down: a scalar has none to touch.
|
|
1224
|
+
const scalar = new Set(measures.map((one) => one.objective));
|
|
1225
|
+
const back = [
|
|
1226
|
+
...worsened(before, after).filter((id) => !scalar.has(id)),
|
|
1227
|
+
...measures.filter((one) => one.back).map((one) => one.objective),
|
|
1228
|
+
].sort();
|
|
1229
|
+
const holdoutTotal = (vector) => Object.entries(vector)
|
|
1230
|
+
.filter(([id]) => !scalar.has(id))
|
|
1231
|
+
.reduce((sum, [, n]) => sum + n, 0);
|
|
1232
|
+
const totalBefore = holdoutTotal(before);
|
|
1233
|
+
const totalAfter = holdoutTotal(after);
|
|
875
1234
|
let ask;
|
|
876
1235
|
let verdict = "ok";
|
|
877
1236
|
if (open)
|
|
@@ -893,6 +1252,16 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
|
|
|
893
1252
|
if (verdict !== "ok" && hotfix !== null && by !== null) {
|
|
894
1253
|
// The escape: the growth is conceded, with a reason naming the hotfix.
|
|
895
1254
|
for (const objective of state.inWindow) {
|
|
1255
|
+
if (objective.measure !== null) {
|
|
1256
|
+
if (measures.some((one) => one.objective === objective.id && one.back)) {
|
|
1257
|
+
concedeMeasure(policy, evaluation, objective, name, {
|
|
1258
|
+
at: policy.now,
|
|
1259
|
+
by,
|
|
1260
|
+
reason: `hotfix: ${hotfix}`,
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
896
1265
|
const ledger = ledgerOf(policy, rule, objective);
|
|
897
1266
|
if (ledger === undefined)
|
|
898
1267
|
continue;
|
|
@@ -935,6 +1304,7 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
|
|
|
935
1304
|
holdouts: { total: own.length, cap: HOLDOUT_CAP, shown },
|
|
936
1305
|
added: added.sort(),
|
|
937
1306
|
removed: removed.sort(),
|
|
1307
|
+
measures,
|
|
938
1308
|
belongsInSector: name === LEGACY_SECTOR ? belongs.sort() : [],
|
|
939
1309
|
});
|
|
940
1310
|
}
|
|
@@ -1000,6 +1370,7 @@ export const renderNudge = (nudge, now) => {
|
|
|
1000
1370
|
? "its objectives"
|
|
1001
1371
|
: `phase ${one.phase.id ?? "done"} (${String(one.phase.index + 1)} of ${String(one.phase.of)}${one.phase.open ? ", open" : ""})`;
|
|
1002
1372
|
const quiet = one.holdouts.total === 0 &&
|
|
1373
|
+
one.measures.every((measure) => measure.before === measure.after) &&
|
|
1003
1374
|
one.direction === "neutral" &&
|
|
1004
1375
|
!one.phase.open &&
|
|
1005
1376
|
one.verdict === "ok" &&
|
|
@@ -1030,6 +1401,17 @@ export const renderNudge = (nudge, now) => {
|
|
|
1030
1401
|
else if (one.direction !== "neutral") {
|
|
1031
1402
|
say(` this diff: ${residueOf(one.residue.before)} → ${residueOf(one.residue.after)} ${one.direction}`);
|
|
1032
1403
|
}
|
|
1404
|
+
for (const measure of one.measures) {
|
|
1405
|
+
if (measure.before === measure.after && !measure.back)
|
|
1406
|
+
continue;
|
|
1407
|
+
const bounds = [
|
|
1408
|
+
...(measure.target === null ? [] : [`target ${String(measure.target)}`]),
|
|
1409
|
+
...(measure.tolerance === 0 ? [] : [`tolerance ${String(measure.tolerance)}`]),
|
|
1410
|
+
];
|
|
1411
|
+
say(` ${measure.objective}: ${measure.before === null ? "unrecorded" : String(measure.before)} → ${measure.after === null ? "no number" : String(measure.after)}` +
|
|
1412
|
+
(bounds.length === 0 ? "" : ` (${bounds.join(", ")})`) +
|
|
1413
|
+
(measure.back ? " back" : ""));
|
|
1414
|
+
}
|
|
1033
1415
|
}
|
|
1034
1416
|
for (const file of one.belongsInSector)
|
|
1035
1417
|
say(` ${file} landed in the legacy inside the scope: this belongs in a sector`);
|
|
@@ -1054,9 +1436,14 @@ export const historyOf = (policy, rule, since, manifestPaths) => {
|
|
|
1054
1436
|
continue;
|
|
1055
1437
|
try {
|
|
1056
1438
|
const raw = JSON.parse(text);
|
|
1439
|
+
// A scalar's column is what its open sectors were held to.
|
|
1057
1440
|
counts[objective.id] =
|
|
1058
|
-
raw.
|
|
1059
|
-
Object.values(raw.sectors ?? {})
|
|
1441
|
+
raw.kind === "measure"
|
|
1442
|
+
? Math.round(Object.values(raw.sectors ?? {})
|
|
1443
|
+
.filter((one) => one.closed === null || one.closed === undefined)
|
|
1444
|
+
.reduce((sum, one) => sum + (one.recorded ?? 0), 0) * 1e6) / 1e6
|
|
1445
|
+
: (raw.entries?.length ??
|
|
1446
|
+
Object.values(raw.sectors ?? {}).reduce((sum, one) => sum + (one.holdouts?.length ?? 0), 0));
|
|
1060
1447
|
}
|
|
1061
1448
|
catch {
|
|
1062
1449
|
// an unreadable ledger at that commit contributes nothing
|
|
@@ -1106,6 +1493,12 @@ export const explainCampaignLines = (policy, evaluation, file) => {
|
|
|
1106
1493
|
return [
|
|
1107
1494
|
` ${rule.name}: sector ${sector}${at}${record?.reached === undefined || record.reached === null ? "" : `, reached ${record.reached}`}`,
|
|
1108
1495
|
` in window: ${state.inWindow.length === 0 ? "(nothing)" : state.inWindow.map((one) => `${one.id}${firing.has(one.id) ? " ✗" : ""}`).join(", ")}`,
|
|
1496
|
+
...state.inWindow
|
|
1497
|
+
.filter((one) => one.measure !== null)
|
|
1498
|
+
.map((one) => {
|
|
1499
|
+
const recorded = measureLedgerOf(policy, rule, one)?.sectors[sector];
|
|
1500
|
+
return ` ${one.id}: the sector measures ${describeValue(state.values[one.id] ?? Number.NaN)}${recorded === undefined || recorded.closed !== null ? ", unrecorded" : `, held to ${String(recorded.recorded)}`}${one.target === null ? "" : `, target ${String(one.target)}`}`;
|
|
1501
|
+
}),
|
|
1109
1502
|
...(own.length === 0
|
|
1110
1503
|
? []
|
|
1111
1504
|
: [
|