@goodbones/campaigns 0.1.0 → 0.1.1-beta.2
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/diff.d.ts +1 -0
- package/build/dts/host/diff.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 +423 -29
- package/build/esm/host/campaigns.js.map +1 -1
- package/build/esm/host/diff.js +24 -0
- package/build/esm/host/diff.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 +2 -2
- 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 +573 -33
- package/src/host/diff.ts +29 -0
- 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
|
-
import { commitOf, commitsTouching, distanceToHunks, materializeTree, readDiff, textAt, } from "./diff.js";
|
|
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
|
};
|
|
@@ -537,6 +732,7 @@ export const authorOf = (given) => {
|
|
|
537
732
|
return given;
|
|
538
733
|
try {
|
|
539
734
|
const email = execFileSync("git", ["config", "user.email"], {
|
|
735
|
+
env: gitEnv(),
|
|
540
736
|
encoding: "utf8",
|
|
541
737
|
stdio: ["ignore", "pipe", "ignore"],
|
|
542
738
|
}).trim();
|
|
@@ -549,6 +745,80 @@ export const authorOf = (given) => {
|
|
|
549
745
|
const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
|
|
550
746
|
return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
|
|
551
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
|
+
};
|
|
552
822
|
// `clear`: the ledger reconciled with the code wherever that is not a
|
|
553
823
|
// regression. Stale holdouts leave and drifted ones are rewritten; a sector
|
|
554
824
|
// newly in an objective's window is recorded with its initial; a sector
|
|
@@ -564,15 +834,19 @@ export const clear = (policy, evaluation, only, by) => {
|
|
|
564
834
|
for (const objective of rule.objectives) {
|
|
565
835
|
if (only !== null && objective.id !== only)
|
|
566
836
|
continue;
|
|
567
|
-
const before = ledgerOf(policy, rule, objective) ?? EMPTY_LEDGER(rule.id, objective.id, policy.now);
|
|
568
|
-
let ledger = before;
|
|
569
|
-
const entered = [];
|
|
570
|
-
const rebaselined = [];
|
|
571
837
|
const phase = rule.phases.find((one) => one.objectives.includes(objective.id));
|
|
572
838
|
// The phase naming the objective, when it changed with a receipt.
|
|
573
839
|
const receipted = phase !== undefined && plan.changed.includes(phase.id) && !plan.unreceipted.includes(phase.id)
|
|
574
840
|
? phase
|
|
575
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 = [];
|
|
576
850
|
let rewritten = 0;
|
|
577
851
|
for (const [name, state] of evaluation.sectors) {
|
|
578
852
|
const inWindow = state.inWindow.some((one) => one.id === objective.id);
|
|
@@ -644,6 +918,12 @@ export const concede = (policy, evaluation, objectiveId, chosen, sector, record)
|
|
|
644
918
|
const objective = rule.objectives.find((one) => one.id === objectiveId);
|
|
645
919
|
if (objective === undefined)
|
|
646
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
|
+
}
|
|
647
927
|
let ledger = ledgerOf(policy, rule, objective);
|
|
648
928
|
if (ledger === undefined) {
|
|
649
929
|
return Result.fail(`objective ${rule.id}/${objectiveId} has no ledger yet; run \`objectives clear ${rule.id}\` first.`);
|
|
@@ -683,6 +963,41 @@ export const concede = (policy, evaluation, objectiveId, chosen, sector, record)
|
|
|
683
963
|
left: unrecorded.filter((one) => !wanted.includes(one)),
|
|
684
964
|
});
|
|
685
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
|
+
};
|
|
686
1001
|
// `attest`: a step no detector sees is recorded done for a sector — with a
|
|
687
1002
|
// reason and evidence — and only while the sector stands at that phase, so
|
|
688
1003
|
// it cannot be recorded ahead and passed through on arrival.
|
|
@@ -737,6 +1052,11 @@ export const baseSideOf = (evaluations) => evaluations.map((evaluation) => ({
|
|
|
737
1052
|
objective: hit.objective,
|
|
738
1053
|
entry: hit.entry,
|
|
739
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
|
+
])),
|
|
740
1060
|
}));
|
|
741
1061
|
// The nudge for one diff. The "before" side is the ledger, or the base tree
|
|
742
1062
|
// evaluated whole in the exact mode; the "after" side is the tree now.
|
|
@@ -784,8 +1104,38 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
|
|
|
784
1104
|
const open = phase !== undefined && isOpenPhase(phase);
|
|
785
1105
|
const onTouch = onTouchOf(rule, state.phase);
|
|
786
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.
|
|
787
1109
|
const before = {};
|
|
1110
|
+
const measures = [];
|
|
788
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
|
+
}
|
|
789
1139
|
if (baseEvaluation !== null) {
|
|
790
1140
|
before[objective.id] = baseEvaluation.sectors[name]?.[objective.id] ?? 0;
|
|
791
1141
|
}
|
|
@@ -868,9 +1218,19 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
|
|
|
868
1218
|
if (state_.stale.length > 0)
|
|
869
1219
|
editsHoldout = true;
|
|
870
1220
|
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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);
|
|
874
1234
|
let ask;
|
|
875
1235
|
let verdict = "ok";
|
|
876
1236
|
if (open)
|
|
@@ -892,6 +1252,16 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
|
|
|
892
1252
|
if (verdict !== "ok" && hotfix !== null && by !== null) {
|
|
893
1253
|
// The escape: the growth is conceded, with a reason naming the hotfix.
|
|
894
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
|
+
}
|
|
895
1265
|
const ledger = ledgerOf(policy, rule, objective);
|
|
896
1266
|
if (ledger === undefined)
|
|
897
1267
|
continue;
|
|
@@ -934,6 +1304,7 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
|
|
|
934
1304
|
holdouts: { total: own.length, cap: HOLDOUT_CAP, shown },
|
|
935
1305
|
added: added.sort(),
|
|
936
1306
|
removed: removed.sort(),
|
|
1307
|
+
measures,
|
|
937
1308
|
belongsInSector: name === LEGACY_SECTOR ? belongs.sort() : [],
|
|
938
1309
|
});
|
|
939
1310
|
}
|
|
@@ -999,6 +1370,7 @@ export const renderNudge = (nudge, now) => {
|
|
|
999
1370
|
? "its objectives"
|
|
1000
1371
|
: `phase ${one.phase.id ?? "done"} (${String(one.phase.index + 1)} of ${String(one.phase.of)}${one.phase.open ? ", open" : ""})`;
|
|
1001
1372
|
const quiet = one.holdouts.total === 0 &&
|
|
1373
|
+
one.measures.every((measure) => measure.before === measure.after) &&
|
|
1002
1374
|
one.direction === "neutral" &&
|
|
1003
1375
|
!one.phase.open &&
|
|
1004
1376
|
one.verdict === "ok" &&
|
|
@@ -1029,6 +1401,17 @@ export const renderNudge = (nudge, now) => {
|
|
|
1029
1401
|
else if (one.direction !== "neutral") {
|
|
1030
1402
|
say(` this diff: ${residueOf(one.residue.before)} → ${residueOf(one.residue.after)} ${one.direction}`);
|
|
1031
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
|
+
}
|
|
1032
1415
|
}
|
|
1033
1416
|
for (const file of one.belongsInSector)
|
|
1034
1417
|
say(` ${file} landed in the legacy inside the scope: this belongs in a sector`);
|
|
@@ -1053,9 +1436,14 @@ export const historyOf = (policy, rule, since, manifestPaths) => {
|
|
|
1053
1436
|
continue;
|
|
1054
1437
|
try {
|
|
1055
1438
|
const raw = JSON.parse(text);
|
|
1439
|
+
// A scalar's column is what its open sectors were held to.
|
|
1056
1440
|
counts[objective.id] =
|
|
1057
|
-
raw.
|
|
1058
|
-
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));
|
|
1059
1447
|
}
|
|
1060
1448
|
catch {
|
|
1061
1449
|
// an unreadable ledger at that commit contributes nothing
|
|
@@ -1105,6 +1493,12 @@ export const explainCampaignLines = (policy, evaluation, file) => {
|
|
|
1105
1493
|
return [
|
|
1106
1494
|
` ${rule.name}: sector ${sector}${at}${record?.reached === undefined || record.reached === null ? "" : `, reached ${record.reached}`}`,
|
|
1107
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
|
+
}),
|
|
1108
1502
|
...(own.length === 0
|
|
1109
1503
|
? []
|
|
1110
1504
|
: [
|