@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
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";
|
|
@@ -88,10 +101,12 @@ import {
|
|
|
88
101
|
commitsTouching,
|
|
89
102
|
type Diff,
|
|
90
103
|
distanceToHunks,
|
|
104
|
+
gitEnv,
|
|
91
105
|
materializeTree,
|
|
92
106
|
readDiff,
|
|
93
107
|
textAt,
|
|
94
108
|
} from "./diff.js";
|
|
109
|
+
import { commandValues } from "./measure-command.js";
|
|
95
110
|
|
|
96
111
|
// The campaigns family, as the CLI runs it: every campaign evaluated over
|
|
97
112
|
// the files it sees, each objective's hits judged against its ledger per
|
|
@@ -247,11 +262,12 @@ export const evaluateCampaigns = (
|
|
|
247
262
|
const projects = campaignsOf(policy).campaignRules.some((rule) => rule.perimeter?.kind === "nx")
|
|
248
263
|
? listWorkspaceProjects(policy.repoRoot, roots)
|
|
249
264
|
: [];
|
|
265
|
+
const commandValueOf = commandValues(policy.repoRoot);
|
|
250
266
|
return campaignsOf(policy).campaignRules.map((rule) => {
|
|
251
267
|
const detectors = [
|
|
252
268
|
...rule.objectives.flatMap((one) => {
|
|
253
269
|
const detect = detectorOf(one);
|
|
254
|
-
return detect === null ? [] : [detect];
|
|
270
|
+
return [...(detect === null ? [] : [detect]), ...measureDetectorsOf(one)];
|
|
255
271
|
}),
|
|
256
272
|
...(rule.perimeter?.kind === "match" ? [rule.perimeter.detect] : []),
|
|
257
273
|
];
|
|
@@ -276,6 +292,7 @@ export const evaluateCampaigns = (
|
|
|
276
292
|
globToRegExp,
|
|
277
293
|
projects,
|
|
278
294
|
recordOf: (sector: string) => campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector)),
|
|
295
|
+
commandValueOf,
|
|
279
296
|
};
|
|
280
297
|
if (!rule.objectives.some((one) => one.endState !== null)) return evaluateCampaign(rule, input);
|
|
281
298
|
// An end state's `{ sector, via }` entries need every sector's root, so
|
|
@@ -303,6 +320,17 @@ export const evaluateCampaigns = (
|
|
|
303
320
|
const ledgerOf = (policy: LoadedPolicy, rule: CompiledCampaign, objective: CompiledObjective) =>
|
|
304
321
|
campaignsOf(policy).ledgers.get(ledgerKeyOf(rule.id, objective.id));
|
|
305
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
|
+
|
|
306
334
|
const recordOf = (policy: LoadedPolicy, rule: CompiledCampaign, sector: string) =>
|
|
307
335
|
campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector));
|
|
308
336
|
|
|
@@ -323,6 +351,14 @@ export type SectorObjectiveReport = {
|
|
|
323
351
|
// The sector is in the objective's window and the ledger has not seen it.
|
|
324
352
|
readonly unrecorded: boolean;
|
|
325
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
|
+
};
|
|
326
362
|
};
|
|
327
363
|
|
|
328
364
|
export type ObjectiveReport = {
|
|
@@ -330,6 +366,14 @@ export type ObjectiveReport = {
|
|
|
330
366
|
readonly count: number;
|
|
331
367
|
readonly ledgered: boolean;
|
|
332
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
|
+
};
|
|
333
377
|
};
|
|
334
378
|
|
|
335
379
|
export type SectorReport = {
|
|
@@ -350,6 +394,9 @@ export type CampaignReport = {
|
|
|
350
394
|
// No ledger for an objective with hits, or a sector in window the ledger
|
|
351
395
|
// has not seen: `objectives clear` has not been run.
|
|
352
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 }>;
|
|
353
400
|
readonly arithmetic: boolean;
|
|
354
401
|
readonly complete: boolean;
|
|
355
402
|
readonly stalled: boolean;
|
|
@@ -386,7 +433,108 @@ export const campaignReportsOf = (
|
|
|
386
433
|
const { rule } = evaluation;
|
|
387
434
|
const counted = hitsInWindow(evaluation);
|
|
388
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
|
+
};
|
|
389
536
|
const objectives: Array<ObjectiveReport> = rule.objectives.map((objective) => {
|
|
537
|
+
if (objective.measure !== null) return scalarReport(objective);
|
|
390
538
|
const ledger = ledgerOf(policy, rule, objective);
|
|
391
539
|
const sectors: Array<SectorObjectiveReport> = [];
|
|
392
540
|
for (const name of sectorNames(evaluation)) {
|
|
@@ -469,6 +617,30 @@ export const campaignReportsOf = (
|
|
|
469
617
|
const ledger = ledgerOf(policy, rule, objective);
|
|
470
618
|
return ledger === undefined ? [] : [ledger];
|
|
471
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
|
+
);
|
|
472
644
|
const sectorClock = [...evaluation.sectors.keys()]
|
|
473
645
|
.map((name) => recordOf(policy, rule, name))
|
|
474
646
|
.filter((one): one is SectorRecord => one !== undefined)
|
|
@@ -478,18 +650,30 @@ export const campaignReportsOf = (
|
|
|
478
650
|
return {
|
|
479
651
|
id: rule.id,
|
|
480
652
|
count,
|
|
481
|
-
new:
|
|
482
|
-
|
|
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
|
+
],
|
|
483
661
|
drifted: objectives.reduce(
|
|
484
662
|
(sum, one) => sum + one.sectors.reduce((inner, two) => inner + two.drifted, 0),
|
|
485
663
|
0,
|
|
486
664
|
),
|
|
487
665
|
missingLedger,
|
|
488
|
-
|
|
489
|
-
|
|
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,
|
|
490
671
|
stalled:
|
|
491
|
-
ledgers.length > 0 &&
|
|
492
|
-
|
|
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
|
+
),
|
|
493
677
|
onComplete: rule.onComplete,
|
|
494
678
|
objectives,
|
|
495
679
|
sectors: [...evaluation.sectors.values()].map((state) => ({
|
|
@@ -504,6 +688,22 @@ export const campaignReportsOf = (
|
|
|
504
688
|
};
|
|
505
689
|
});
|
|
506
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
|
+
|
|
507
707
|
// Which hits in window are carried by a ledger, exactly or by anchor.
|
|
508
708
|
export const ledgeredFilter = (
|
|
509
709
|
policy: LoadedPolicy,
|
|
@@ -543,6 +743,9 @@ export const campaignFailuresOf = (
|
|
|
543
743
|
...campaigns
|
|
544
744
|
.filter((one) => one.plan.unreceipted.length > 0)
|
|
545
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`),
|
|
546
749
|
...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
|
|
547
750
|
...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
|
|
548
751
|
...campaigns.filter((one) => one.missingLedger).map((one) => `campaign ${one.id} has no ledger`),
|
|
@@ -592,8 +795,19 @@ export const renderCampaignReports = (
|
|
|
592
795
|
if (campaign.missingLedger) {
|
|
593
796
|
const unrecorded = campaign.objectives.flatMap((objective) =>
|
|
594
797
|
objective.sectors
|
|
595
|
-
.filter(
|
|
596
|
-
|
|
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
|
+
),
|
|
597
811
|
);
|
|
598
812
|
say(
|
|
599
813
|
"",
|
|
@@ -603,6 +817,13 @@ export const renderCampaignReports = (
|
|
|
603
817
|
` architecture objectives clear ${campaign.id}`,
|
|
604
818
|
);
|
|
605
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
|
+
}
|
|
606
827
|
if (campaign.new.length > 0 && !campaign.missingLedger) {
|
|
607
828
|
say(
|
|
608
829
|
"",
|
|
@@ -635,7 +856,7 @@ export const renderCampaignReports = (
|
|
|
635
856
|
if (!campaign.arithmetic) {
|
|
636
857
|
say(
|
|
637
858
|
"",
|
|
638
|
-
`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\`.`,
|
|
639
860
|
);
|
|
640
861
|
}
|
|
641
862
|
const complete = campaign.complete && !campaign.missingLedger;
|
|
@@ -681,10 +902,46 @@ export const snapshotCampaignsOf = (
|
|
|
681
902
|
if (evaluation === undefined) throw new Error("report without evaluation");
|
|
682
903
|
const { rule } = evaluation;
|
|
683
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
|
+
);
|
|
684
908
|
const objectives = rule.objectives.map((objective, j) => {
|
|
685
909
|
const ledger = ledgers[j];
|
|
686
910
|
const own = report.objectives[j];
|
|
687
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
|
+
}
|
|
688
945
|
if (ledger === undefined) {
|
|
689
946
|
return {
|
|
690
947
|
id: objective.id,
|
|
@@ -765,13 +1022,21 @@ export const snapshotCampaignsOf = (
|
|
|
765
1022
|
legacy: {
|
|
766
1023
|
files: evaluation.index.legacy.length,
|
|
767
1024
|
holdouts:
|
|
768
|
-
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),
|
|
769
1030
|
},
|
|
770
1031
|
plan: report.plan,
|
|
771
1032
|
stalled: report.stalled,
|
|
772
1033
|
complete: report.complete,
|
|
773
1034
|
onComplete: rule.onComplete,
|
|
774
|
-
ledgered:
|
|
1035
|
+
ledgered: rule.objectives.every((objective, j) =>
|
|
1036
|
+
objective.measure === null
|
|
1037
|
+
? ledgers[j] !== undefined
|
|
1038
|
+
: measureLedgerOf(policy, rule, objective) !== undefined,
|
|
1039
|
+
),
|
|
775
1040
|
};
|
|
776
1041
|
});
|
|
777
1042
|
|
|
@@ -807,12 +1072,17 @@ export const renderCampaignRows = (
|
|
|
807
1072
|
.join(" → ")}` +
|
|
808
1073
|
(one.legacy.files > 0 ? ` · legacy ${count(one.legacy.files, "file")}` : ""),
|
|
809
1074
|
]),
|
|
810
|
-
...one.objectives.map(
|
|
811
|
-
|
|
812
|
-
` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} ${String(objective.
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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"),
|
|
816
1086
|
),
|
|
817
1087
|
];
|
|
818
1088
|
});
|
|
@@ -834,6 +1104,7 @@ export const authorOf = (given: string | undefined): string | null => {
|
|
|
834
1104
|
if (given !== undefined && given !== "") return given;
|
|
835
1105
|
try {
|
|
836
1106
|
const email = execFileSync("git", ["config", "user.email"], {
|
|
1107
|
+
env: gitEnv(),
|
|
837
1108
|
encoding: "utf8",
|
|
838
1109
|
stdio: ["ignore", "pipe", "ignore"],
|
|
839
1110
|
}).trim();
|
|
@@ -854,6 +1125,103 @@ export type ClearOutcome = {
|
|
|
854
1125
|
readonly entered: ReadonlyArray<string>;
|
|
855
1126
|
readonly rebaselined: ReadonlyArray<string>;
|
|
856
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
|
+
};
|
|
857
1225
|
};
|
|
858
1226
|
|
|
859
1227
|
// `clear`: the ledger reconciled with the code wherever that is not a
|
|
@@ -875,17 +1243,21 @@ export const clear = (
|
|
|
875
1243
|
const outcomes: Array<ClearOutcome> = [];
|
|
876
1244
|
for (const objective of rule.objectives) {
|
|
877
1245
|
if (only !== null && objective.id !== only) continue;
|
|
878
|
-
const before =
|
|
879
|
-
ledgerOf(policy, rule, objective) ?? EMPTY_LEDGER(rule.id, objective.id, policy.now);
|
|
880
|
-
let ledger = before;
|
|
881
|
-
const entered: Array<string> = [];
|
|
882
|
-
const rebaselined: Array<string> = [];
|
|
883
1246
|
const phase = rule.phases.find((one) => one.objectives.includes(objective.id));
|
|
884
1247
|
// The phase naming the objective, when it changed with a receipt.
|
|
885
1248
|
const receipted =
|
|
886
1249
|
phase !== undefined && plan.changed.includes(phase.id) && !plan.unreceipted.includes(phase.id)
|
|
887
1250
|
? phase
|
|
888
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> = [];
|
|
889
1261
|
let rewritten = 0;
|
|
890
1262
|
for (const [name, state] of evaluation.sectors) {
|
|
891
1263
|
const inWindow = state.inWindow.some((one) => one.id === objective.id);
|
|
@@ -979,6 +1351,14 @@ export const concede = (
|
|
|
979
1351
|
const objective = rule.objectives.find((one) => one.id === objectiveId);
|
|
980
1352
|
if (objective === undefined)
|
|
981
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
|
+
}
|
|
982
1362
|
let ledger = ledgerOf(policy, rule, objective);
|
|
983
1363
|
if (ledger === undefined) {
|
|
984
1364
|
return Result.fail(
|
|
@@ -1029,6 +1409,52 @@ export const concede = (
|
|
|
1029
1409
|
});
|
|
1030
1410
|
};
|
|
1031
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
|
+
|
|
1032
1458
|
// `attest`: a step no detector sees is recorded done for a sector — with a
|
|
1033
1459
|
// reason and evidence — and only while the sector stands at that phase, so
|
|
1034
1460
|
// it cannot be recorded ahead and passed through on arrival.
|
|
@@ -1135,6 +1561,19 @@ export type SectorNudge = {
|
|
|
1135
1561
|
readonly holdouts: { total: number; cap: number; shown: ReadonlyArray<NudgeHoldout> };
|
|
1136
1562
|
readonly added: ReadonlyArray<string>;
|
|
1137
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
|
+
}>;
|
|
1138
1577
|
// Files this diff added inside the scope that no sector claims.
|
|
1139
1578
|
readonly belongsInSector: ReadonlyArray<string>;
|
|
1140
1579
|
};
|
|
@@ -1160,6 +1599,9 @@ export type BaseSide = ReadonlyArray<{
|
|
|
1160
1599
|
readonly id: string;
|
|
1161
1600
|
readonly sectors: Readonly<Record<string, Readonly<Record<string, number>>>>;
|
|
1162
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>>>>;
|
|
1163
1605
|
}>;
|
|
1164
1606
|
|
|
1165
1607
|
export const baseSideOf = (evaluations: ReadonlyArray<CampaignEvaluation>): BaseSide =>
|
|
@@ -1173,6 +1615,15 @@ export const baseSideOf = (evaluations: ReadonlyArray<CampaignEvaluation>): Base
|
|
|
1173
1615
|
objective: hit.objective,
|
|
1174
1616
|
entry: hit.entry,
|
|
1175
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
|
+
),
|
|
1176
1627
|
}));
|
|
1177
1628
|
|
|
1178
1629
|
// The nudge for one diff. The "before" side is the ledger, or the base tree
|
|
@@ -1223,8 +1674,44 @@ export const nudgeOf = (
|
|
|
1223
1674
|
const open = phase !== undefined && isOpenPhase(phase);
|
|
1224
1675
|
const onTouch = onTouchOf(rule, state.phase);
|
|
1225
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.
|
|
1226
1679
|
const before: Record<string, number> = {};
|
|
1680
|
+
const measures: Array<SectorNudge["measures"][number]> = [];
|
|
1227
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
|
+
}
|
|
1228
1715
|
if (baseEvaluation !== null) {
|
|
1229
1716
|
before[objective.id] = baseEvaluation.sectors[name]?.[objective.id] ?? 0;
|
|
1230
1717
|
} else {
|
|
@@ -1309,9 +1796,20 @@ export const nudgeOf = (
|
|
|
1309
1796
|
}
|
|
1310
1797
|
if (state_.stale.length > 0) editsHoldout = true;
|
|
1311
1798
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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);
|
|
1315
1813
|
let ask: Ask;
|
|
1316
1814
|
let verdict: Verdict = "ok";
|
|
1317
1815
|
if (open) ask = "note";
|
|
@@ -1327,6 +1825,16 @@ export const nudgeOf = (
|
|
|
1327
1825
|
if (verdict !== "ok" && hotfix !== null && by !== null) {
|
|
1328
1826
|
// The escape: the growth is conceded, with a reason naming the hotfix.
|
|
1329
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
|
+
}
|
|
1330
1838
|
const ledger = ledgerOf(policy, rule, objective);
|
|
1331
1839
|
if (ledger === undefined) continue;
|
|
1332
1840
|
const entries = own
|
|
@@ -1372,6 +1880,7 @@ export const nudgeOf = (
|
|
|
1372
1880
|
holdouts: { total: own.length, cap: HOLDOUT_CAP, shown },
|
|
1373
1881
|
added: added.sort(),
|
|
1374
1882
|
removed: removed.sort(),
|
|
1883
|
+
measures,
|
|
1375
1884
|
belongsInSector: name === LEGACY_SECTOR ? belongs.sort() : [],
|
|
1376
1885
|
});
|
|
1377
1886
|
}
|
|
@@ -1444,6 +1953,7 @@ export const renderNudge = (nudge: Nudge, now: number): ReadonlyArray<string> =>
|
|
|
1444
1953
|
: `phase ${one.phase.id ?? "done"} (${String(one.phase.index + 1)} of ${String(one.phase.of)}${one.phase.open ? ", open" : ""})`;
|
|
1445
1954
|
const quiet =
|
|
1446
1955
|
one.holdouts.total === 0 &&
|
|
1956
|
+
one.measures.every((measure) => measure.before === measure.after) &&
|
|
1447
1957
|
one.direction === "neutral" &&
|
|
1448
1958
|
!one.phase.open &&
|
|
1449
1959
|
one.verdict === "ok" &&
|
|
@@ -1487,6 +1997,18 @@ export const renderNudge = (nudge: Nudge, now: number): ReadonlyArray<string> =>
|
|
|
1487
1997
|
` this diff: ${residueOf(one.residue.before)} → ${residueOf(one.residue.after)} ${one.direction}`,
|
|
1488
1998
|
);
|
|
1489
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
|
+
}
|
|
1490
2012
|
}
|
|
1491
2013
|
for (const file of one.belongsInSector)
|
|
1492
2014
|
say(` ${file} landed in the legacy inside the scope: this belongs in a sector`);
|
|
@@ -1504,7 +2026,8 @@ export type HistoryRow = {
|
|
|
1504
2026
|
readonly sha: string;
|
|
1505
2027
|
readonly at: string;
|
|
1506
2028
|
readonly subject: string;
|
|
1507
|
-
// 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.
|
|
1508
2031
|
readonly counts: Readonly<Record<string, number>>;
|
|
1509
2032
|
readonly planChanged: boolean;
|
|
1510
2033
|
};
|
|
@@ -1533,15 +2056,26 @@ export const historyOf = (
|
|
|
1533
2056
|
if (text === null) continue;
|
|
1534
2057
|
try {
|
|
1535
2058
|
const raw = JSON.parse(text) as {
|
|
1536
|
-
|
|
2059
|
+
kind?: string;
|
|
2060
|
+
sectors?: Record<
|
|
2061
|
+
string,
|
|
2062
|
+
{ holdouts?: Array<unknown>; recorded?: number; closed?: number | null }
|
|
2063
|
+
>;
|
|
1537
2064
|
entries?: Array<unknown>;
|
|
1538
2065
|
};
|
|
2066
|
+
// A scalar's column is what its open sectors were held to.
|
|
1539
2067
|
counts[objective.id] =
|
|
1540
|
-
raw.
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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
|
+
));
|
|
1545
2079
|
} catch {
|
|
1546
2080
|
// an unreadable ledger at that commit contributes nothing
|
|
1547
2081
|
}
|
|
@@ -1602,6 +2136,12 @@ export const explainCampaignLines = (
|
|
|
1602
2136
|
return [
|
|
1603
2137
|
` ${rule.name}: sector ${sector}${at}${record?.reached === undefined || record.reached === null ? "" : `, reached ${record.reached}`}`,
|
|
1604
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
|
+
}),
|
|
1605
2145
|
...(own.length === 0
|
|
1606
2146
|
? []
|
|
1607
2147
|
: [
|