@goodbones/campaigns 0.1.1-beta.1 → 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.
Files changed (55) hide show
  1. package/build/dts/core/campaign-state.d.ts +3 -1
  2. package/build/dts/core/campaign-state.d.ts.map +1 -1
  3. package/build/dts/core/campaigns.d.ts +46 -2
  4. package/build/dts/core/campaigns.d.ts.map +1 -1
  5. package/build/dts/core/ledger.d.ts +58 -2
  6. package/build/dts/core/ledger.d.ts.map +1 -1
  7. package/build/dts/domain/config.d.ts +204 -2
  8. package/build/dts/domain/config.d.ts.map +1 -1
  9. package/build/dts/host/campaigns.d.ts +34 -0
  10. package/build/dts/host/campaigns.d.ts.map +1 -1
  11. package/build/dts/host/measure-command.d.ts +3 -0
  12. package/build/dts/host/measure-command.d.ts.map +1 -0
  13. package/build/dts/index.d.ts +5 -4
  14. package/build/dts/index.d.ts.map +1 -1
  15. package/build/dts/load/extension.d.ts +2 -1
  16. package/build/dts/load/extension.d.ts.map +1 -1
  17. package/build/dts/manifest/lower.d.ts.map +1 -1
  18. package/build/dts/manifest/spec.d.ts +209 -3
  19. package/build/dts/manifest/spec.d.ts.map +1 -1
  20. package/build/dts/ports/campaign-measure.d.ts +3 -0
  21. package/build/dts/ports/campaign-measure.d.ts.map +1 -0
  22. package/build/esm/core/campaign-state.js +35 -2
  23. package/build/esm/core/campaign-state.js.map +1 -1
  24. package/build/esm/core/campaigns.js +177 -2
  25. package/build/esm/core/campaigns.js.map +1 -1
  26. package/build/esm/core/ledger.js +215 -0
  27. package/build/esm/core/ledger.js.map +1 -1
  28. package/build/esm/domain/config.js +40 -4
  29. package/build/esm/domain/config.js.map +1 -1
  30. package/build/esm/host/campaigns.js +421 -28
  31. package/build/esm/host/campaigns.js.map +1 -1
  32. package/build/esm/host/measure-command.js +35 -0
  33. package/build/esm/host/measure-command.js.map +1 -0
  34. package/build/esm/index.js +2 -2
  35. package/build/esm/index.js.map +1 -1
  36. package/build/esm/load/extension.js +57 -13
  37. package/build/esm/load/extension.js.map +1 -1
  38. package/build/esm/manifest/lower.js +68 -1
  39. package/build/esm/manifest/lower.js.map +1 -1
  40. package/build/esm/manifest/spec.js +85 -5
  41. package/build/esm/manifest/spec.js.map +1 -1
  42. package/build/esm/ports/campaign-measure.js +2 -0
  43. package/build/esm/ports/campaign-measure.js.map +1 -0
  44. package/package.json +2 -2
  45. package/src/core/campaign-state.ts +49 -2
  46. package/src/core/campaigns.ts +232 -3
  47. package/src/core/ledger.ts +264 -2
  48. package/src/domain/config.ts +46 -4
  49. package/src/host/campaigns.ts +571 -33
  50. package/src/host/measure-command.ts +36 -0
  51. package/src/index.ts +31 -0
  52. package/src/load/extension.ts +70 -6
  53. package/src/manifest/lower.ts +80 -1
  54. package/src/manifest/spec.ts +100 -5
  55. package/src/ports/campaign-measure.ts +11 -0
@@ -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: flat((one) => one.new),
483
- stale: flat((one) => one.stale),
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
- arithmetic: ledgers.every(ledgerArithmeticHolds),
490
- complete: count === 0 && ledgers.every(isComplete),
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
- ledgers.some((ledger) => isStalled(rule, ledger, policy.now, sectorClock)),
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((one) => one.unrecorded && (one.count > 0 || objective.ledgered))
597
- .map((one) => ` ${objective.id} · ${one.sector} (${count(one.count, "hit")})`),
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). A holdout was added by hand; remove it, or record it with \`objectives concede\`.`,
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 ? 0 : Object.values(legacy.residue).reduce((a, b) => a + b, 0),
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: ledgers.every((one) => one !== undefined),
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
- (objective) =>
813
- ` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} ${String(objective.count).padStart(5)} left` +
814
- ` ${String(objective.cleared)} cleared ${String(objective.allowed)} conceded` +
815
- (objective.closed > 0 ? ` ${String(objective.closed)} closed` : "") +
816
- (objective.ledgered ? "" : " no ledger"),
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
- const back = worsened(before, after);
1315
- const totalBefore = Object.values(before).reduce((a, b) => a + b, 0);
1316
- const totalAfter = Object.values(after).reduce((a, b) => a + b, 0);
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
- sectors?: Record<string, { holdouts?: Array<unknown> }>;
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.entries?.length ??
1543
- Object.values(raw.sectors ?? {}).reduce(
1544
- (sum, one) => sum + (one.holdouts?.length ?? 0),
1545
- 0,
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
  : [