@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
package/src/host/campaigns.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
breaches,
|
|
6
7
|
compileExportRules,
|
|
7
8
|
compileImportRules,
|
|
8
9
|
compileMemberRules,
|
|
@@ -39,20 +40,30 @@ import {
|
|
|
39
40
|
type CompiledCampaign,
|
|
40
41
|
type CompiledObjective,
|
|
41
42
|
detectorOf,
|
|
43
|
+
distanceToTarget,
|
|
44
|
+
measureDetectorsOf,
|
|
42
45
|
needsSyntax,
|
|
43
46
|
} from "../core/campaigns.js";
|
|
44
47
|
import {
|
|
45
48
|
attestedRecord,
|
|
49
|
+
clearedMeasure,
|
|
46
50
|
clearedSector,
|
|
51
|
+
concededMeasure,
|
|
47
52
|
concededSector,
|
|
48
53
|
EMPTY_LEDGER,
|
|
54
|
+
EMPTY_MEASURE_LEDGER,
|
|
49
55
|
EMPTY_SECTOR_RECORD,
|
|
50
56
|
isComplete,
|
|
51
57
|
isStalled,
|
|
52
58
|
lastClearedOf,
|
|
59
|
+
lastImprovedOf,
|
|
53
60
|
type Ledger,
|
|
54
61
|
ledgerArithmeticHolds,
|
|
55
62
|
ledgerPathOf,
|
|
63
|
+
type MeasureLedger,
|
|
64
|
+
measureLedgerArithmeticHolds,
|
|
65
|
+
measureProgressOf,
|
|
66
|
+
measureStandingOf,
|
|
56
67
|
notedRecord,
|
|
57
68
|
planDiffOf,
|
|
58
69
|
planOf,
|
|
@@ -61,11 +72,13 @@ import {
|
|
|
61
72
|
reachedRecord,
|
|
62
73
|
rebaselinedSector,
|
|
63
74
|
reconcileSector,
|
|
75
|
+
recordedOf,
|
|
64
76
|
sectorArithmeticHolds,
|
|
65
77
|
sectorClockOf,
|
|
66
78
|
type SectorRecord,
|
|
67
79
|
sectorRecordPathOf,
|
|
68
80
|
serializeLedger,
|
|
81
|
+
serializeMeasureLedger,
|
|
69
82
|
serializePlanRecord,
|
|
70
83
|
serializeSectorRecord,
|
|
71
84
|
} from "../core/ledger.js";
|
|
@@ -93,6 +106,7 @@ import {
|
|
|
93
106
|
readDiff,
|
|
94
107
|
textAt,
|
|
95
108
|
} from "./diff.js";
|
|
109
|
+
import { commandValues } from "./measure-command.js";
|
|
96
110
|
|
|
97
111
|
// The campaigns family, as the CLI runs it: every campaign evaluated over
|
|
98
112
|
// the files it sees, each objective's hits judged against its ledger per
|
|
@@ -248,11 +262,12 @@ export const evaluateCampaigns = (
|
|
|
248
262
|
const projects = campaignsOf(policy).campaignRules.some((rule) => rule.perimeter?.kind === "nx")
|
|
249
263
|
? listWorkspaceProjects(policy.repoRoot, roots)
|
|
250
264
|
: [];
|
|
265
|
+
const commandValueOf = commandValues(policy.repoRoot);
|
|
251
266
|
return campaignsOf(policy).campaignRules.map((rule) => {
|
|
252
267
|
const detectors = [
|
|
253
268
|
...rule.objectives.flatMap((one) => {
|
|
254
269
|
const detect = detectorOf(one);
|
|
255
|
-
return detect === null ? [] : [detect];
|
|
270
|
+
return [...(detect === null ? [] : [detect]), ...measureDetectorsOf(one)];
|
|
256
271
|
}),
|
|
257
272
|
...(rule.perimeter?.kind === "match" ? [rule.perimeter.detect] : []),
|
|
258
273
|
];
|
|
@@ -277,6 +292,7 @@ export const evaluateCampaigns = (
|
|
|
277
292
|
globToRegExp,
|
|
278
293
|
projects,
|
|
279
294
|
recordOf: (sector: string) => campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector)),
|
|
295
|
+
commandValueOf,
|
|
280
296
|
};
|
|
281
297
|
if (!rule.objectives.some((one) => one.endState !== null)) return evaluateCampaign(rule, input);
|
|
282
298
|
// An end state's `{ sector, via }` entries need every sector's root, so
|
|
@@ -304,6 +320,17 @@ export const evaluateCampaigns = (
|
|
|
304
320
|
const ledgerOf = (policy: LoadedPolicy, rule: CompiledCampaign, objective: CompiledObjective) =>
|
|
305
321
|
campaignsOf(policy).ledgers.get(ledgerKeyOf(rule.id, objective.id));
|
|
306
322
|
|
|
323
|
+
const measureLedgerOf = (
|
|
324
|
+
policy: LoadedPolicy,
|
|
325
|
+
rule: CompiledCampaign,
|
|
326
|
+
objective: CompiledObjective,
|
|
327
|
+
): MeasureLedger | undefined =>
|
|
328
|
+
campaignsOf(policy).measureLedgers.get(ledgerKeyOf(rule.id, objective.id));
|
|
329
|
+
|
|
330
|
+
// How a scalar reads in a line: its value, and against what.
|
|
331
|
+
const describeValue = (value: number): string =>
|
|
332
|
+
Number.isNaN(value) ? "no number" : String(value);
|
|
333
|
+
|
|
307
334
|
const recordOf = (policy: LoadedPolicy, rule: CompiledCampaign, sector: string) =>
|
|
308
335
|
campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector));
|
|
309
336
|
|
|
@@ -324,6 +351,14 @@ export type SectorObjectiveReport = {
|
|
|
324
351
|
// The sector is in the objective's window and the ledger has not seen it.
|
|
325
352
|
readonly unrecorded: boolean;
|
|
326
353
|
readonly arithmetic: boolean;
|
|
354
|
+
// For a scalar objective: the value measured, the value the ledger holds
|
|
355
|
+
// the sector to (`null` before `clear` records it), and where the one
|
|
356
|
+
// stands against the other.
|
|
357
|
+
readonly measure?: {
|
|
358
|
+
readonly value: number | null;
|
|
359
|
+
readonly recorded: number | null;
|
|
360
|
+
readonly standing: "within" | "breached" | "surpassed" | "unrecorded" | "unmeasured";
|
|
361
|
+
};
|
|
327
362
|
};
|
|
328
363
|
|
|
329
364
|
export type ObjectiveReport = {
|
|
@@ -331,6 +366,14 @@ export type ObjectiveReport = {
|
|
|
331
366
|
readonly count: number;
|
|
332
367
|
readonly ledgered: boolean;
|
|
333
368
|
readonly sectors: ReadonlyArray<SectorObjectiveReport>;
|
|
369
|
+
// For a scalar objective: its number across the sectors in window.
|
|
370
|
+
readonly measure?: {
|
|
371
|
+
readonly direction: "down" | "up";
|
|
372
|
+
readonly value: number | null;
|
|
373
|
+
readonly recorded: number | null;
|
|
374
|
+
readonly target: number | null;
|
|
375
|
+
readonly tolerance: number;
|
|
376
|
+
};
|
|
334
377
|
};
|
|
335
378
|
|
|
336
379
|
export type SectorReport = {
|
|
@@ -351,6 +394,9 @@ export type CampaignReport = {
|
|
|
351
394
|
// No ledger for an objective with hits, or a sector in window the ledger
|
|
352
395
|
// has not seen: `objectives clear` has not been run.
|
|
353
396
|
readonly missingLedger: boolean;
|
|
397
|
+
// Scalar objectives that measured no number in a sector in window: a
|
|
398
|
+
// function that answered something else, a command that did not run.
|
|
399
|
+
readonly unmeasured: ReadonlyArray<{ objective: string; sector: string }>;
|
|
354
400
|
readonly arithmetic: boolean;
|
|
355
401
|
readonly complete: boolean;
|
|
356
402
|
readonly stalled: boolean;
|
|
@@ -387,7 +433,108 @@ export const campaignReportsOf = (
|
|
|
387
433
|
const { rule } = evaluation;
|
|
388
434
|
const counted = hitsInWindow(evaluation);
|
|
389
435
|
let missingLedger = false;
|
|
436
|
+
const unmeasured: Array<{ objective: string; sector: string }> = [];
|
|
437
|
+
const scalarNew: Array<{ objective: string; sector: string; entry: string }> = [];
|
|
438
|
+
const scalarStale: Array<{ objective: string; sector: string; entry: string }> = [];
|
|
439
|
+
// A scalar objective, reconciled per sector against its ledger. A value
|
|
440
|
+
// worse than the record past the tolerance is growth `concede` records;
|
|
441
|
+
// better past it, progress `clear` records — each a line of the same
|
|
442
|
+
// `new` and `stale` lists a holdout lands in, so `check` fails on them
|
|
443
|
+
// for the same reasons and says the same next step.
|
|
444
|
+
const scalarReport = (objective: CompiledObjective): ObjectiveReport => {
|
|
445
|
+
const ledger = measureLedgerOf(policy, rule, objective);
|
|
446
|
+
const sectors: Array<SectorObjectiveReport> = [];
|
|
447
|
+
let value = 0;
|
|
448
|
+
let recorded = 0;
|
|
449
|
+
let measuredAll = true;
|
|
450
|
+
let recordedAll = true;
|
|
451
|
+
for (const name of sectorNames(evaluation)) {
|
|
452
|
+
const state = evaluation.sectors.get(name);
|
|
453
|
+
if (state === undefined) continue;
|
|
454
|
+
const measured = state.values[objective.id] ?? Number.NaN;
|
|
455
|
+
const own = ledger?.sectors[name];
|
|
456
|
+
if (!state.inWindow.some((one) => one.id === objective.id)) {
|
|
457
|
+
// Past the window: a record still open is closed by `clear`.
|
|
458
|
+
if (own?.closed === null) {
|
|
459
|
+
scalarStale.push({
|
|
460
|
+
objective: objective.id,
|
|
461
|
+
sector: name,
|
|
462
|
+
entry: `left the window at ${describeValue(measured)}`,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (Number.isNaN(measured)) {
|
|
468
|
+
measuredAll = false;
|
|
469
|
+
unmeasured.push({ objective: objective.id, sector: name });
|
|
470
|
+
} else value += measured;
|
|
471
|
+
if (ledger === undefined || own === undefined || own.closed !== null) {
|
|
472
|
+
missingLedger = true;
|
|
473
|
+
recordedAll = false;
|
|
474
|
+
sectors.push({
|
|
475
|
+
sector: name,
|
|
476
|
+
count: 0,
|
|
477
|
+
new: [],
|
|
478
|
+
stale: [],
|
|
479
|
+
drifted: 0,
|
|
480
|
+
unrecorded: true,
|
|
481
|
+
arithmetic: true,
|
|
482
|
+
measure: {
|
|
483
|
+
value: Number.isNaN(measured) ? null : measured,
|
|
484
|
+
recorded: null,
|
|
485
|
+
standing: Number.isNaN(measured) ? "unmeasured" : "unrecorded",
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
recorded += own.recorded;
|
|
491
|
+
const standing = Number.isNaN(measured)
|
|
492
|
+
? "unmeasured"
|
|
493
|
+
: measureStandingOf(ledger, name, measured, objective.tolerance);
|
|
494
|
+
const line = `measured ${describeValue(measured)}, recorded ${String(own.recorded)}`;
|
|
495
|
+
if (standing === "breached") {
|
|
496
|
+
scalarNew.push({ objective: objective.id, sector: name, entry: line });
|
|
497
|
+
}
|
|
498
|
+
if (standing === "surpassed") {
|
|
499
|
+
scalarStale.push({ objective: objective.id, sector: name, entry: line });
|
|
500
|
+
}
|
|
501
|
+
sectors.push({
|
|
502
|
+
sector: name,
|
|
503
|
+
count: 0,
|
|
504
|
+
new: standing === "breached" ? [line] : [],
|
|
505
|
+
stale: standing === "surpassed" ? [line] : [],
|
|
506
|
+
drifted: 0,
|
|
507
|
+
unrecorded: false,
|
|
508
|
+
arithmetic: measureLedgerArithmeticHolds(ledger),
|
|
509
|
+
measure: {
|
|
510
|
+
value: Number.isNaN(measured) ? null : measured,
|
|
511
|
+
recorded: own.recorded,
|
|
512
|
+
standing,
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
// Sectors the ledger records that the code no longer births: closed
|
|
517
|
+
// on the next `clear`, stale until then.
|
|
518
|
+
for (const [name, own] of Object.entries(ledger?.sectors ?? {})) {
|
|
519
|
+
if (evaluation.sectors.has(name) || own.closed !== null) continue;
|
|
520
|
+
scalarStale.push({ objective: objective.id, sector: name, entry: "no longer a sector" });
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
id: objective.id,
|
|
524
|
+
count: 0,
|
|
525
|
+
ledgered: ledger !== undefined,
|
|
526
|
+
sectors,
|
|
527
|
+
measure: {
|
|
528
|
+
direction: objective.direction,
|
|
529
|
+
value: measuredAll ? value : null,
|
|
530
|
+
recorded: recordedAll ? recorded : null,
|
|
531
|
+
target: objective.target,
|
|
532
|
+
tolerance: objective.tolerance,
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
};
|
|
390
536
|
const objectives: Array<ObjectiveReport> = rule.objectives.map((objective) => {
|
|
537
|
+
if (objective.measure !== null) return scalarReport(objective);
|
|
391
538
|
const ledger = ledgerOf(policy, rule, objective);
|
|
392
539
|
const sectors: Array<SectorObjectiveReport> = [];
|
|
393
540
|
for (const name of sectorNames(evaluation)) {
|
|
@@ -470,6 +617,30 @@ export const campaignReportsOf = (
|
|
|
470
617
|
const ledger = ledgerOf(policy, rule, objective);
|
|
471
618
|
return ledger === undefined ? [] : [ledger];
|
|
472
619
|
});
|
|
620
|
+
const measureLedgers = rule.objectives.flatMap((objective) => {
|
|
621
|
+
const ledger = measureLedgerOf(policy, rule, objective);
|
|
622
|
+
return ledger === undefined ? [] : [{ objective, ledger }];
|
|
623
|
+
});
|
|
624
|
+
// A targeted scalar is met in every sector in window when its distance
|
|
625
|
+
// is 0 there; a standing one never holds a campaign open.
|
|
626
|
+
const scalarsMet = rule.objectives
|
|
627
|
+
.filter((objective) => objective.measure !== null && objective.target !== null)
|
|
628
|
+
.every((objective) =>
|
|
629
|
+
[...evaluation.sectors.values()].every(
|
|
630
|
+
(state) =>
|
|
631
|
+
!state.inWindow.some((one) => one.id === objective.id) ||
|
|
632
|
+
(state.counts[objective.id] ?? 0) === 0,
|
|
633
|
+
),
|
|
634
|
+
);
|
|
635
|
+
const scalarsOpen = measureLedgers.filter(
|
|
636
|
+
({ objective }) =>
|
|
637
|
+
objective.target !== null &&
|
|
638
|
+
[...evaluation.sectors.values()].some(
|
|
639
|
+
(state) =>
|
|
640
|
+
state.inWindow.some((one) => one.id === objective.id) &&
|
|
641
|
+
(state.counts[objective.id] ?? 0) > 0,
|
|
642
|
+
),
|
|
643
|
+
);
|
|
473
644
|
const sectorClock = [...evaluation.sectors.keys()]
|
|
474
645
|
.map((name) => recordOf(policy, rule, name))
|
|
475
646
|
.filter((one): one is SectorRecord => one !== undefined)
|
|
@@ -479,18 +650,30 @@ export const campaignReportsOf = (
|
|
|
479
650
|
return {
|
|
480
651
|
id: rule.id,
|
|
481
652
|
count,
|
|
482
|
-
new:
|
|
483
|
-
|
|
653
|
+
new: [
|
|
654
|
+
...flat((one) => (one.measure === undefined ? one.new : [])),
|
|
655
|
+
...scalarNew,
|
|
656
|
+
],
|
|
657
|
+
stale: [
|
|
658
|
+
...flat((one) => (one.measure === undefined ? one.stale : [])),
|
|
659
|
+
...scalarStale,
|
|
660
|
+
],
|
|
484
661
|
drifted: objectives.reduce(
|
|
485
662
|
(sum, one) => sum + one.sectors.reduce((inner, two) => inner + two.drifted, 0),
|
|
486
663
|
0,
|
|
487
664
|
),
|
|
488
665
|
missingLedger,
|
|
489
|
-
|
|
490
|
-
|
|
666
|
+
unmeasured,
|
|
667
|
+
arithmetic:
|
|
668
|
+
ledgers.every(ledgerArithmeticHolds) &&
|
|
669
|
+
measureLedgers.every(({ ledger }) => measureLedgerArithmeticHolds(ledger)),
|
|
670
|
+
complete: count === 0 && ledgers.every(isComplete) && scalarsMet && unmeasured.length === 0,
|
|
491
671
|
stalled:
|
|
492
|
-
ledgers.length > 0 &&
|
|
493
|
-
|
|
672
|
+
(ledgers.length > 0 &&
|
|
673
|
+
ledgers.some((ledger) => isStalled(rule, ledger, policy.now, sectorClock))) ||
|
|
674
|
+
scalarsOpen.some(({ ledger }) =>
|
|
675
|
+
isMeasureStalled(rule, ledger, policy.now, sectorClock),
|
|
676
|
+
),
|
|
494
677
|
onComplete: rule.onComplete,
|
|
495
678
|
objectives,
|
|
496
679
|
sectors: [...evaluation.sectors.values()].map((state) => ({
|
|
@@ -505,6 +688,22 @@ export const campaignReportsOf = (
|
|
|
505
688
|
};
|
|
506
689
|
});
|
|
507
690
|
|
|
691
|
+
// A scalar short of its target is stalled when its record has not improved,
|
|
692
|
+
// and no sector has been attested or noted, within the campaign's
|
|
693
|
+
// `staleAfter`.
|
|
694
|
+
const isMeasureStalled = (
|
|
695
|
+
rule: CompiledCampaign,
|
|
696
|
+
ledger: MeasureLedger,
|
|
697
|
+
now: number,
|
|
698
|
+
sectorClock: string | null,
|
|
699
|
+
): boolean => {
|
|
700
|
+
if (rule.staleAfter === null) return false;
|
|
701
|
+
const last = [lastImprovedOf(ledger), sectorClock]
|
|
702
|
+
.filter((one): one is string => one !== null)
|
|
703
|
+
.reduce((a, b) => (a > b ? a : b), ledger.created);
|
|
704
|
+
return now - Date.parse(last) > rule.staleAfter;
|
|
705
|
+
};
|
|
706
|
+
|
|
508
707
|
// Which hits in window are carried by a ledger, exactly or by anchor.
|
|
509
708
|
export const ledgeredFilter = (
|
|
510
709
|
policy: LoadedPolicy,
|
|
@@ -544,6 +743,9 @@ export const campaignFailuresOf = (
|
|
|
544
743
|
...campaigns
|
|
545
744
|
.filter((one) => one.plan.unreceipted.length > 0)
|
|
546
745
|
.map((one) => `campaign ${one.id}: a defined phase changed without a concession`),
|
|
746
|
+
...campaigns
|
|
747
|
+
.filter((one) => one.unmeasured.length > 0)
|
|
748
|
+
.map((one) => `campaign ${one.id}: a scalar objective measured no number`),
|
|
547
749
|
...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
|
|
548
750
|
...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
|
|
549
751
|
...campaigns.filter((one) => one.missingLedger).map((one) => `campaign ${one.id} has no ledger`),
|
|
@@ -593,8 +795,19 @@ export const renderCampaignReports = (
|
|
|
593
795
|
if (campaign.missingLedger) {
|
|
594
796
|
const unrecorded = campaign.objectives.flatMap((objective) =>
|
|
595
797
|
objective.sectors
|
|
596
|
-
.filter(
|
|
597
|
-
|
|
798
|
+
.filter(
|
|
799
|
+
(one) =>
|
|
800
|
+
one.unrecorded &&
|
|
801
|
+
(one.count > 0 || objective.ledgered || one.measure !== undefined),
|
|
802
|
+
)
|
|
803
|
+
.map(
|
|
804
|
+
(one) =>
|
|
805
|
+
` ${objective.id} · ${one.sector} (${
|
|
806
|
+
one.measure === undefined
|
|
807
|
+
? count(one.count, "hit")
|
|
808
|
+
: `measures ${one.measure.value === null ? "no number" : String(one.measure.value)}`
|
|
809
|
+
})`,
|
|
810
|
+
),
|
|
598
811
|
);
|
|
599
812
|
say(
|
|
600
813
|
"",
|
|
@@ -604,6 +817,13 @@ export const renderCampaignReports = (
|
|
|
604
817
|
` architecture objectives clear ${campaign.id}`,
|
|
605
818
|
);
|
|
606
819
|
}
|
|
820
|
+
if (campaign.unmeasured.length > 0) {
|
|
821
|
+
say(
|
|
822
|
+
"",
|
|
823
|
+
`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:`,
|
|
824
|
+
...campaign.unmeasured.map((one) => ` ${one.objective} · ${one.sector}`),
|
|
825
|
+
);
|
|
826
|
+
}
|
|
607
827
|
if (campaign.new.length > 0 && !campaign.missingLedger) {
|
|
608
828
|
say(
|
|
609
829
|
"",
|
|
@@ -636,7 +856,7 @@ export const renderCampaignReports = (
|
|
|
636
856
|
if (!campaign.arithmetic) {
|
|
637
857
|
say(
|
|
638
858
|
"",
|
|
639
|
-
`campaign ${campaign.id}: a ledger does not add up (holdouts ≠ initial + conceded − cleared − closed).
|
|
859
|
+
`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\`.`,
|
|
640
860
|
);
|
|
641
861
|
}
|
|
642
862
|
const complete = campaign.complete && !campaign.missingLedger;
|
|
@@ -682,10 +902,46 @@ export const snapshotCampaignsOf = (
|
|
|
682
902
|
if (evaluation === undefined) throw new Error("report without evaluation");
|
|
683
903
|
const { rule } = evaluation;
|
|
684
904
|
const ledgers = rule.objectives.map((objective) => ledgerOf(policy, rule, objective));
|
|
905
|
+
const scalar = new Set(
|
|
906
|
+
rule.objectives.filter((one) => one.measure !== null).map((one) => one.id),
|
|
907
|
+
);
|
|
685
908
|
const objectives = rule.objectives.map((objective, j) => {
|
|
686
909
|
const ledger = ledgers[j];
|
|
687
910
|
const own = report.objectives[j];
|
|
688
911
|
const phase = rule.phases.find((one) => one.objectives.includes(objective.id))?.id ?? null;
|
|
912
|
+
if (objective.measure !== null) {
|
|
913
|
+
// A scalar: the holdout counts are all 0, and the number is under
|
|
914
|
+
// `measure`. Met when every sector in window is at its target.
|
|
915
|
+
const measured = measureLedgerOf(policy, rule, objective);
|
|
916
|
+
const met =
|
|
917
|
+
objective.target !== null &&
|
|
918
|
+
(own?.sectors ?? []).every(
|
|
919
|
+
(one) => (evaluation.sectors.get(one.sector)?.counts[objective.id] ?? 1) === 0,
|
|
920
|
+
);
|
|
921
|
+
return {
|
|
922
|
+
id: objective.id,
|
|
923
|
+
phase,
|
|
924
|
+
initial: 0,
|
|
925
|
+
allowed: 0,
|
|
926
|
+
count: 0,
|
|
927
|
+
cleared: 0,
|
|
928
|
+
closed: 0,
|
|
929
|
+
progress:
|
|
930
|
+
measured === undefined ? 0 : measureProgressOf(measured, objective.target),
|
|
931
|
+
lastCleared: measured === undefined ? null : lastImprovedOf(measured),
|
|
932
|
+
concessions: measured?.concessions.length ?? 0,
|
|
933
|
+
complete: met,
|
|
934
|
+
ledgered: measured !== undefined,
|
|
935
|
+
measure: {
|
|
936
|
+
direction: objective.direction,
|
|
937
|
+
value: own?.measure?.value ?? 0,
|
|
938
|
+
recorded:
|
|
939
|
+
own?.measure?.recorded ?? (measured === undefined ? 0 : recordedOf(measured)),
|
|
940
|
+
target: objective.target,
|
|
941
|
+
tolerance: objective.tolerance,
|
|
942
|
+
},
|
|
943
|
+
};
|
|
944
|
+
}
|
|
689
945
|
if (ledger === undefined) {
|
|
690
946
|
return {
|
|
691
947
|
id: objective.id,
|
|
@@ -766,13 +1022,21 @@ export const snapshotCampaignsOf = (
|
|
|
766
1022
|
legacy: {
|
|
767
1023
|
files: evaluation.index.legacy.length,
|
|
768
1024
|
holdouts:
|
|
769
|
-
legacy === undefined
|
|
1025
|
+
legacy === undefined
|
|
1026
|
+
? 0
|
|
1027
|
+
: Object.entries(legacy.residue)
|
|
1028
|
+
.filter(([id]) => !scalar.has(id))
|
|
1029
|
+
.reduce((a, [, n]) => a + n, 0),
|
|
770
1030
|
},
|
|
771
1031
|
plan: report.plan,
|
|
772
1032
|
stalled: report.stalled,
|
|
773
1033
|
complete: report.complete,
|
|
774
1034
|
onComplete: rule.onComplete,
|
|
775
|
-
ledgered:
|
|
1035
|
+
ledgered: rule.objectives.every((objective, j) =>
|
|
1036
|
+
objective.measure === null
|
|
1037
|
+
? ledgers[j] !== undefined
|
|
1038
|
+
: measureLedgerOf(policy, rule, objective) !== undefined,
|
|
1039
|
+
),
|
|
776
1040
|
};
|
|
777
1041
|
});
|
|
778
1042
|
|
|
@@ -808,12 +1072,17 @@ export const renderCampaignRows = (
|
|
|
808
1072
|
.join(" → ")}` +
|
|
809
1073
|
(one.legacy.files > 0 ? ` · legacy ${count(one.legacy.files, "file")}` : ""),
|
|
810
1074
|
]),
|
|
811
|
-
...one.objectives.map(
|
|
812
|
-
|
|
813
|
-
` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} ${String(objective.
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1075
|
+
...one.objectives.map((objective) =>
|
|
1076
|
+
objective.measure !== undefined
|
|
1077
|
+
? ` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} measures ${String(objective.measure.value)}, held to ${String(objective.measure.recorded)}` +
|
|
1078
|
+
(objective.measure.target === null
|
|
1079
|
+
? ""
|
|
1080
|
+
: `, target ${String(objective.measure.target)}`) +
|
|
1081
|
+
(objective.ledgered ? "" : " no ledger")
|
|
1082
|
+
: ` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} ${String(objective.count).padStart(5)} left` +
|
|
1083
|
+
` ${String(objective.cleared)} cleared ${String(objective.allowed)} conceded` +
|
|
1084
|
+
(objective.closed > 0 ? ` ${String(objective.closed)} closed` : "") +
|
|
1085
|
+
(objective.ledgered ? "" : " no ledger"),
|
|
817
1086
|
),
|
|
818
1087
|
];
|
|
819
1088
|
});
|
|
@@ -856,6 +1125,103 @@ export type ClearOutcome = {
|
|
|
856
1125
|
readonly entered: ReadonlyArray<string>;
|
|
857
1126
|
readonly rebaselined: ReadonlyArray<string>;
|
|
858
1127
|
readonly left: number;
|
|
1128
|
+
// For a scalar objective: the sectors whose record improved, and what the
|
|
1129
|
+
// open sectors are held to now, summed. `cleared`, `rewritten` and `left`
|
|
1130
|
+
// are 0; `closed` counts sectors.
|
|
1131
|
+
readonly measure?: {
|
|
1132
|
+
readonly improved: ReadonlyArray<{ sector: string; from: number; to: number }>;
|
|
1133
|
+
readonly recorded: number;
|
|
1134
|
+
};
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
// `clear` for a scalar objective: a sector entering the window is recorded
|
|
1138
|
+
// at its value; one inside it whose value improved past the tolerance is
|
|
1139
|
+
// held to that value from now; one past it, or no longer born, is closed;
|
|
1140
|
+
// and a receipted phase change re-baselines the sectors in window to what
|
|
1141
|
+
// they measure, with a concession. A rise is left where it is.
|
|
1142
|
+
const clearMeasure = (
|
|
1143
|
+
policy: LoadedPolicy,
|
|
1144
|
+
evaluation: CampaignEvaluation,
|
|
1145
|
+
objective: CompiledObjective,
|
|
1146
|
+
receipted: PhaseRule | null,
|
|
1147
|
+
by: string,
|
|
1148
|
+
): ClearOutcome => {
|
|
1149
|
+
const { rule } = evaluation;
|
|
1150
|
+
const existing = measureLedgerOf(policy, rule, objective);
|
|
1151
|
+
const before =
|
|
1152
|
+
existing ?? EMPTY_MEASURE_LEDGER(rule.id, objective.id, objective.direction, policy.now);
|
|
1153
|
+
let ledger = before;
|
|
1154
|
+
const entered: Array<string> = [];
|
|
1155
|
+
const rebaselined: Array<string> = [];
|
|
1156
|
+
const improved: Array<{ sector: string; from: number; to: number }> = [];
|
|
1157
|
+
let closed = 0;
|
|
1158
|
+
for (const [name, state] of evaluation.sectors) {
|
|
1159
|
+
const value = state.values[objective.id] ?? Number.NaN;
|
|
1160
|
+
const own = ledger.sectors[name];
|
|
1161
|
+
if (!state.inWindow.some((one) => one.id === objective.id)) {
|
|
1162
|
+
const next = clearedMeasure(ledger, name, value, objective.tolerance, policy.now, "outside");
|
|
1163
|
+
if (next !== ledger) closed += 1;
|
|
1164
|
+
ledger = next;
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
if (own === undefined || own.closed !== null) {
|
|
1168
|
+
const next = clearedMeasure(
|
|
1169
|
+
ledger,
|
|
1170
|
+
name,
|
|
1171
|
+
value,
|
|
1172
|
+
objective.tolerance,
|
|
1173
|
+
policy.now,
|
|
1174
|
+
"inside",
|
|
1175
|
+
by,
|
|
1176
|
+
);
|
|
1177
|
+
if (next !== ledger) entered.push(name);
|
|
1178
|
+
ledger = next;
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
if (receipted !== null) {
|
|
1182
|
+
const next = concededMeasure(ledger, name, value, {
|
|
1183
|
+
at: policy.now,
|
|
1184
|
+
by,
|
|
1185
|
+
reason: `phase ${receipted.id} changed: ${receipted.concessions.at(-1)?.reason ?? ""}`,
|
|
1186
|
+
});
|
|
1187
|
+
if (next !== ledger) rebaselined.push(name);
|
|
1188
|
+
ledger = next;
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
const next = clearedMeasure(ledger, name, value, objective.tolerance, policy.now, "inside");
|
|
1192
|
+
if (next !== ledger) {
|
|
1193
|
+
improved.push({
|
|
1194
|
+
sector: name,
|
|
1195
|
+
from: own.recorded,
|
|
1196
|
+
to: next.sectors[name]?.recorded ?? own.recorded,
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
ledger = next;
|
|
1200
|
+
}
|
|
1201
|
+
// Sectors the code no longer births are past every window.
|
|
1202
|
+
for (const [name, own] of Object.entries(ledger.sectors)) {
|
|
1203
|
+
if (evaluation.sectors.has(name) || own.closed !== null) continue;
|
|
1204
|
+
ledger = clearedMeasure(ledger, name, own.recorded, objective.tolerance, policy.now, "outside");
|
|
1205
|
+
closed += 1;
|
|
1206
|
+
}
|
|
1207
|
+
if (ledger !== before || existing === undefined) {
|
|
1208
|
+
writeJson(
|
|
1209
|
+
policy.repoRoot,
|
|
1210
|
+
ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id),
|
|
1211
|
+
serializeMeasureLedger(ledger),
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
return {
|
|
1215
|
+
campaign: rule.id,
|
|
1216
|
+
objective: objective.id,
|
|
1217
|
+
cleared: 0,
|
|
1218
|
+
rewritten: 0,
|
|
1219
|
+
closed,
|
|
1220
|
+
entered,
|
|
1221
|
+
rebaselined,
|
|
1222
|
+
left: 0,
|
|
1223
|
+
measure: { improved, recorded: recordedOf(ledger) },
|
|
1224
|
+
};
|
|
859
1225
|
};
|
|
860
1226
|
|
|
861
1227
|
// `clear`: the ledger reconciled with the code wherever that is not a
|
|
@@ -877,17 +1243,21 @@ export const clear = (
|
|
|
877
1243
|
const outcomes: Array<ClearOutcome> = [];
|
|
878
1244
|
for (const objective of rule.objectives) {
|
|
879
1245
|
if (only !== null && objective.id !== only) continue;
|
|
880
|
-
const before =
|
|
881
|
-
ledgerOf(policy, rule, objective) ?? EMPTY_LEDGER(rule.id, objective.id, policy.now);
|
|
882
|
-
let ledger = before;
|
|
883
|
-
const entered: Array<string> = [];
|
|
884
|
-
const rebaselined: Array<string> = [];
|
|
885
1246
|
const phase = rule.phases.find((one) => one.objectives.includes(objective.id));
|
|
886
1247
|
// The phase naming the objective, when it changed with a receipt.
|
|
887
1248
|
const receipted =
|
|
888
1249
|
phase !== undefined && plan.changed.includes(phase.id) && !plan.unreceipted.includes(phase.id)
|
|
889
1250
|
? phase
|
|
890
1251
|
: null;
|
|
1252
|
+
if (objective.measure !== null) {
|
|
1253
|
+
outcomes.push(clearMeasure(policy, evaluation, objective, receipted, by));
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
const before =
|
|
1257
|
+
ledgerOf(policy, rule, objective) ?? EMPTY_LEDGER(rule.id, objective.id, policy.now);
|
|
1258
|
+
let ledger = before;
|
|
1259
|
+
const entered: Array<string> = [];
|
|
1260
|
+
const rebaselined: Array<string> = [];
|
|
891
1261
|
let rewritten = 0;
|
|
892
1262
|
for (const [name, state] of evaluation.sectors) {
|
|
893
1263
|
const inWindow = state.inWindow.some((one) => one.id === objective.id);
|
|
@@ -981,6 +1351,14 @@ export const concede = (
|
|
|
981
1351
|
const objective = rule.objectives.find((one) => one.id === objectiveId);
|
|
982
1352
|
if (objective === undefined)
|
|
983
1353
|
return Result.fail(`no objective of ${rule.id} is named "${objectiveId}"`);
|
|
1354
|
+
if (objective.measure !== null) {
|
|
1355
|
+
if (chosen !== null) {
|
|
1356
|
+
return Result.fail(
|
|
1357
|
+
`${rule.id}/${objectiveId} is a scalar objective: it has no holdouts to choose among. Narrow with --sector.`,
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
return concedeMeasure(policy, evaluation, objective, sector, record);
|
|
1361
|
+
}
|
|
984
1362
|
let ledger = ledgerOf(policy, rule, objective);
|
|
985
1363
|
if (ledger === undefined) {
|
|
986
1364
|
return Result.fail(
|
|
@@ -1031,6 +1409,52 @@ export const concede = (
|
|
|
1031
1409
|
});
|
|
1032
1410
|
};
|
|
1033
1411
|
|
|
1412
|
+
// `concede` for a scalar objective: each sector in window whose value is
|
|
1413
|
+
// worse than its record past the tolerance is held to its value from now,
|
|
1414
|
+
// with a concession naming the reason and the author. A sector the ledger
|
|
1415
|
+
// has not recorded is `clear`'s to enter first.
|
|
1416
|
+
const concedeMeasure = (
|
|
1417
|
+
policy: LoadedPolicy,
|
|
1418
|
+
evaluation: CampaignEvaluation,
|
|
1419
|
+
objective: CompiledObjective,
|
|
1420
|
+
sector: string | null,
|
|
1421
|
+
record: { at: number; by: string; reason: string },
|
|
1422
|
+
): Result.Result<ConcedeOutcome, string> => {
|
|
1423
|
+
const { rule } = evaluation;
|
|
1424
|
+
let ledger = measureLedgerOf(policy, rule, objective);
|
|
1425
|
+
if (ledger === undefined) {
|
|
1426
|
+
return Result.fail(
|
|
1427
|
+
`objective ${rule.id}/${objective.id} has no ledger yet; run \`objectives clear ${rule.id}\` first.`,
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
const conceded: Array<{ sector: string; entry: string }> = [];
|
|
1431
|
+
const left: Array<{ sector: string; entry: string }> = [];
|
|
1432
|
+
for (const [name, state] of evaluation.sectors) {
|
|
1433
|
+
if (!state.inWindow.some((one) => one.id === objective.id)) continue;
|
|
1434
|
+
const value = state.values[objective.id] ?? Number.NaN;
|
|
1435
|
+
const standing = Number.isNaN(value)
|
|
1436
|
+
? "unmeasured"
|
|
1437
|
+
: measureStandingOf(ledger, name, value, objective.tolerance);
|
|
1438
|
+
if (standing !== "breached") continue;
|
|
1439
|
+
const from = ledger.sectors[name]?.recorded ?? value;
|
|
1440
|
+
const entry = `${String(from)} → ${String(value)}`;
|
|
1441
|
+
if (sector !== null && name !== sector) {
|
|
1442
|
+
left.push({ sector: name, entry });
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
ledger = concededMeasure(ledger, name, value, record);
|
|
1446
|
+
conceded.push({ sector: name, entry });
|
|
1447
|
+
}
|
|
1448
|
+
if (conceded.length > 0) {
|
|
1449
|
+
writeJson(
|
|
1450
|
+
policy.repoRoot,
|
|
1451
|
+
ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id),
|
|
1452
|
+
serializeMeasureLedger(ledger),
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
return Result.succeed({ objective: objective.id, conceded, left });
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1034
1458
|
// `attest`: a step no detector sees is recorded done for a sector — with a
|
|
1035
1459
|
// reason and evidence — and only while the sector stands at that phase, so
|
|
1036
1460
|
// it cannot be recorded ahead and passed through on arrival.
|
|
@@ -1137,6 +1561,19 @@ export type SectorNudge = {
|
|
|
1137
1561
|
readonly holdouts: { total: number; cap: number; shown: ReadonlyArray<NudgeHoldout> };
|
|
1138
1562
|
readonly added: ReadonlyArray<string>;
|
|
1139
1563
|
readonly removed: ReadonlyArray<string>;
|
|
1564
|
+
// The scalar objectives in window: each one's value before and after,
|
|
1565
|
+
// and whether it went back past its tolerance. `before` is the ledger's
|
|
1566
|
+
// record, or the base tree's value in the exact mode; `null` where
|
|
1567
|
+
// neither has one.
|
|
1568
|
+
readonly measures: ReadonlyArray<{
|
|
1569
|
+
readonly objective: string;
|
|
1570
|
+
readonly direction: "down" | "up";
|
|
1571
|
+
readonly before: number | null;
|
|
1572
|
+
readonly after: number | null;
|
|
1573
|
+
readonly target: number | null;
|
|
1574
|
+
readonly tolerance: number;
|
|
1575
|
+
readonly back: boolean;
|
|
1576
|
+
}>;
|
|
1140
1577
|
// Files this diff added inside the scope that no sector claims.
|
|
1141
1578
|
readonly belongsInSector: ReadonlyArray<string>;
|
|
1142
1579
|
};
|
|
@@ -1162,6 +1599,9 @@ export type BaseSide = ReadonlyArray<{
|
|
|
1162
1599
|
readonly id: string;
|
|
1163
1600
|
readonly sectors: Readonly<Record<string, Readonly<Record<string, number>>>>;
|
|
1164
1601
|
readonly hits: ReadonlyArray<{ sector: string; objective: string; entry: string }>;
|
|
1602
|
+
// Each scalar objective's value per sector. Absent from a base side cached
|
|
1603
|
+
// before scalars existed, and then the ledger stands in for it.
|
|
1604
|
+
readonly values?: Readonly<Record<string, Readonly<Record<string, number | null>>>>;
|
|
1165
1605
|
}>;
|
|
1166
1606
|
|
|
1167
1607
|
export const baseSideOf = (evaluations: ReadonlyArray<CampaignEvaluation>): BaseSide =>
|
|
@@ -1175,6 +1615,15 @@ export const baseSideOf = (evaluations: ReadonlyArray<CampaignEvaluation>): Base
|
|
|
1175
1615
|
objective: hit.objective,
|
|
1176
1616
|
entry: hit.entry,
|
|
1177
1617
|
})),
|
|
1618
|
+
// `NaN` does not survive the JSON cache; `null` is "no number".
|
|
1619
|
+
values: Object.fromEntries(
|
|
1620
|
+
[...evaluation.sectors.values()].map((state) => [
|
|
1621
|
+
state.name,
|
|
1622
|
+
Object.fromEntries(
|
|
1623
|
+
Object.entries(state.values).map(([id, value]) => [id, Number.isNaN(value) ? null : value]),
|
|
1624
|
+
),
|
|
1625
|
+
]),
|
|
1626
|
+
),
|
|
1178
1627
|
}));
|
|
1179
1628
|
|
|
1180
1629
|
// The nudge for one diff. The "before" side is the ledger, or the base tree
|
|
@@ -1225,8 +1674,44 @@ export const nudgeOf = (
|
|
|
1225
1674
|
const open = phase !== undefined && isOpenPhase(phase);
|
|
1226
1675
|
const onTouch = onTouchOf(rule, state.phase);
|
|
1227
1676
|
// Before: the ledger's count per objective in window, or the base tree's.
|
|
1677
|
+
// A scalar's is its distance to target from the value before: the
|
|
1678
|
+
// base tree's, or the ledger's record.
|
|
1228
1679
|
const before: Record<string, number> = {};
|
|
1680
|
+
const measures: Array<SectorNudge["measures"][number]> = [];
|
|
1229
1681
|
for (const objective of state.inWindow) {
|
|
1682
|
+
if (objective.measure !== null) {
|
|
1683
|
+
const recorded = measureLedgerOf(policy, rule, objective)?.sectors[name];
|
|
1684
|
+
const baseValue =
|
|
1685
|
+
baseEvaluation?.values === undefined
|
|
1686
|
+
? undefined
|
|
1687
|
+
: baseEvaluation.values[name]?.[objective.id];
|
|
1688
|
+
const was =
|
|
1689
|
+
baseValue !== undefined
|
|
1690
|
+
? baseValue
|
|
1691
|
+
: recorded === undefined || recorded.closed !== null
|
|
1692
|
+
? null
|
|
1693
|
+
: recorded.recorded;
|
|
1694
|
+
const value = state.values[objective.id] ?? Number.NaN;
|
|
1695
|
+
const now_ = Number.isNaN(value) ? null : value;
|
|
1696
|
+
before[objective.id] =
|
|
1697
|
+
was === null ? (state.counts[objective.id] ?? 0) : distanceToTarget(objective, was);
|
|
1698
|
+
measures.push({
|
|
1699
|
+
objective: objective.id,
|
|
1700
|
+
direction: objective.direction,
|
|
1701
|
+
before: was,
|
|
1702
|
+
after: now_,
|
|
1703
|
+
target: objective.target,
|
|
1704
|
+
tolerance: objective.tolerance,
|
|
1705
|
+
back:
|
|
1706
|
+
was !== null &&
|
|
1707
|
+
(now_ === null ||
|
|
1708
|
+
breaches(
|
|
1709
|
+
{ direction: objective.direction, limit: was, tolerance: objective.tolerance },
|
|
1710
|
+
now_,
|
|
1711
|
+
)),
|
|
1712
|
+
});
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1230
1715
|
if (baseEvaluation !== null) {
|
|
1231
1716
|
before[objective.id] = baseEvaluation.sectors[name]?.[objective.id] ?? 0;
|
|
1232
1717
|
} else {
|
|
@@ -1311,9 +1796,20 @@ export const nudgeOf = (
|
|
|
1311
1796
|
}
|
|
1312
1797
|
if (state_.stale.length > 0) editsHoldout = true;
|
|
1313
1798
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1799
|
+
// A scalar goes back by its tolerance, not by any rise of its
|
|
1800
|
+
// distance; the holdout dimensions go back by any rise at all. Only
|
|
1801
|
+
// the holdouts are paid down: a scalar has none to touch.
|
|
1802
|
+
const scalar = new Set(measures.map((one) => one.objective));
|
|
1803
|
+
const back = [
|
|
1804
|
+
...worsened(before, after).filter((id) => !scalar.has(id)),
|
|
1805
|
+
...measures.filter((one) => one.back).map((one) => one.objective),
|
|
1806
|
+
].sort();
|
|
1807
|
+
const holdoutTotal = (vector: ResidueVector): number =>
|
|
1808
|
+
Object.entries(vector)
|
|
1809
|
+
.filter(([id]) => !scalar.has(id))
|
|
1810
|
+
.reduce((sum, [, n]) => sum + n, 0);
|
|
1811
|
+
const totalBefore = holdoutTotal(before);
|
|
1812
|
+
const totalAfter = holdoutTotal(after);
|
|
1317
1813
|
let ask: Ask;
|
|
1318
1814
|
let verdict: Verdict = "ok";
|
|
1319
1815
|
if (open) ask = "note";
|
|
@@ -1329,6 +1825,16 @@ export const nudgeOf = (
|
|
|
1329
1825
|
if (verdict !== "ok" && hotfix !== null && by !== null) {
|
|
1330
1826
|
// The escape: the growth is conceded, with a reason naming the hotfix.
|
|
1331
1827
|
for (const objective of state.inWindow) {
|
|
1828
|
+
if (objective.measure !== null) {
|
|
1829
|
+
if (measures.some((one) => one.objective === objective.id && one.back)) {
|
|
1830
|
+
concedeMeasure(policy, evaluation, objective, name, {
|
|
1831
|
+
at: policy.now,
|
|
1832
|
+
by,
|
|
1833
|
+
reason: `hotfix: ${hotfix}`,
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1332
1838
|
const ledger = ledgerOf(policy, rule, objective);
|
|
1333
1839
|
if (ledger === undefined) continue;
|
|
1334
1840
|
const entries = own
|
|
@@ -1374,6 +1880,7 @@ export const nudgeOf = (
|
|
|
1374
1880
|
holdouts: { total: own.length, cap: HOLDOUT_CAP, shown },
|
|
1375
1881
|
added: added.sort(),
|
|
1376
1882
|
removed: removed.sort(),
|
|
1883
|
+
measures,
|
|
1377
1884
|
belongsInSector: name === LEGACY_SECTOR ? belongs.sort() : [],
|
|
1378
1885
|
});
|
|
1379
1886
|
}
|
|
@@ -1446,6 +1953,7 @@ export const renderNudge = (nudge: Nudge, now: number): ReadonlyArray<string> =>
|
|
|
1446
1953
|
: `phase ${one.phase.id ?? "done"} (${String(one.phase.index + 1)} of ${String(one.phase.of)}${one.phase.open ? ", open" : ""})`;
|
|
1447
1954
|
const quiet =
|
|
1448
1955
|
one.holdouts.total === 0 &&
|
|
1956
|
+
one.measures.every((measure) => measure.before === measure.after) &&
|
|
1449
1957
|
one.direction === "neutral" &&
|
|
1450
1958
|
!one.phase.open &&
|
|
1451
1959
|
one.verdict === "ok" &&
|
|
@@ -1489,6 +1997,18 @@ export const renderNudge = (nudge: Nudge, now: number): ReadonlyArray<string> =>
|
|
|
1489
1997
|
` this diff: ${residueOf(one.residue.before)} → ${residueOf(one.residue.after)} ${one.direction}`,
|
|
1490
1998
|
);
|
|
1491
1999
|
}
|
|
2000
|
+
for (const measure of one.measures) {
|
|
2001
|
+
if (measure.before === measure.after && !measure.back) continue;
|
|
2002
|
+
const bounds = [
|
|
2003
|
+
...(measure.target === null ? [] : [`target ${String(measure.target)}`]),
|
|
2004
|
+
...(measure.tolerance === 0 ? [] : [`tolerance ${String(measure.tolerance)}`]),
|
|
2005
|
+
];
|
|
2006
|
+
say(
|
|
2007
|
+
` ${measure.objective}: ${measure.before === null ? "unrecorded" : String(measure.before)} → ${measure.after === null ? "no number" : String(measure.after)}` +
|
|
2008
|
+
(bounds.length === 0 ? "" : ` (${bounds.join(", ")})`) +
|
|
2009
|
+
(measure.back ? " back" : ""),
|
|
2010
|
+
);
|
|
2011
|
+
}
|
|
1492
2012
|
}
|
|
1493
2013
|
for (const file of one.belongsInSector)
|
|
1494
2014
|
say(` ${file} landed in the legacy inside the scope: this belongs in a sector`);
|
|
@@ -1506,7 +2026,8 @@ export type HistoryRow = {
|
|
|
1506
2026
|
readonly sha: string;
|
|
1507
2027
|
readonly at: string;
|
|
1508
2028
|
readonly subject: string;
|
|
1509
|
-
// Holdouts per objective, as the ledgers stood after the commit
|
|
2029
|
+
// Holdouts per objective, as the ledgers stood after the commit; for a
|
|
2030
|
+
// scalar, the value its open sectors were held to.
|
|
1510
2031
|
readonly counts: Readonly<Record<string, number>>;
|
|
1511
2032
|
readonly planChanged: boolean;
|
|
1512
2033
|
};
|
|
@@ -1535,15 +2056,26 @@ export const historyOf = (
|
|
|
1535
2056
|
if (text === null) continue;
|
|
1536
2057
|
try {
|
|
1537
2058
|
const raw = JSON.parse(text) as {
|
|
1538
|
-
|
|
2059
|
+
kind?: string;
|
|
2060
|
+
sectors?: Record<
|
|
2061
|
+
string,
|
|
2062
|
+
{ holdouts?: Array<unknown>; recorded?: number; closed?: number | null }
|
|
2063
|
+
>;
|
|
1539
2064
|
entries?: Array<unknown>;
|
|
1540
2065
|
};
|
|
2066
|
+
// A scalar's column is what its open sectors were held to.
|
|
1541
2067
|
counts[objective.id] =
|
|
1542
|
-
raw.
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
2068
|
+
raw.kind === "measure"
|
|
2069
|
+
? Math.round(
|
|
2070
|
+
Object.values(raw.sectors ?? {})
|
|
2071
|
+
.filter((one) => one.closed === null || one.closed === undefined)
|
|
2072
|
+
.reduce((sum, one) => sum + (one.recorded ?? 0), 0) * 1e6,
|
|
2073
|
+
) / 1e6
|
|
2074
|
+
: (raw.entries?.length ??
|
|
2075
|
+
Object.values(raw.sectors ?? {}).reduce(
|
|
2076
|
+
(sum, one) => sum + (one.holdouts?.length ?? 0),
|
|
2077
|
+
0,
|
|
2078
|
+
));
|
|
1547
2079
|
} catch {
|
|
1548
2080
|
// an unreadable ledger at that commit contributes nothing
|
|
1549
2081
|
}
|
|
@@ -1604,6 +2136,12 @@ export const explainCampaignLines = (
|
|
|
1604
2136
|
return [
|
|
1605
2137
|
` ${rule.name}: sector ${sector}${at}${record?.reached === undefined || record.reached === null ? "" : `, reached ${record.reached}`}`,
|
|
1606
2138
|
` in window: ${state.inWindow.length === 0 ? "(nothing)" : state.inWindow.map((one) => `${one.id}${firing.has(one.id) ? " ✗" : ""}`).join(", ")}`,
|
|
2139
|
+
...state.inWindow
|
|
2140
|
+
.filter((one) => one.measure !== null)
|
|
2141
|
+
.map((one) => {
|
|
2142
|
+
const recorded = measureLedgerOf(policy, rule, one)?.sectors[sector];
|
|
2143
|
+
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)}`}`;
|
|
2144
|
+
}),
|
|
1607
2145
|
...(own.length === 0
|
|
1608
2146
|
? []
|
|
1609
2147
|
: [
|