@boxxy/planning 0.1.0 → 0.1.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/README.md CHANGED
@@ -12,7 +12,8 @@
12
12
  </div>
13
13
 
14
14
  boxxy's planning. `buildFrame` runs the plug-ins of the loaded rules over a snapshot of the open
15
- items, and `decide` picks the next item phase for each free pool slot.
15
+ items, and `decide` picks the wakes and the item phases that start, while each limit has a free
16
+ seat.
16
17
 
17
18
  The package does no I/O: the caller passes in the snapshot, the loaded rules and the time.
18
19
 
@@ -31,7 +32,9 @@ The package runs on [Bun](https://bun.sh) 1.3 or newer.
31
32
  - `buildFrame`, which returns the candidates, their holds, lifts, signal values and filter-field
32
33
  values, and each plug-in fault
33
34
  ([planning: buildFrame and decide](../../docs/design/boxxy/slice-1.md#planning-buildframe-and-decide)).
34
- - `decide`, which picks one candidate per free slot, pool by pool, and never names a concept.
35
+ - `decide`, which puts each wake first, then picks pool by pool while each limit that counts a pick
36
+ has a free seat, gives the reason for each item and wake it passes over, and never names a concept
37
+ ([planning: limits and the decision](../../docs/design/boxxy/planning.md#limits-and-the-decision)).
35
38
  - `concepts`, the `pool`, `defer` and `expedite` concepts
36
39
  ([the slice 1 concepts](../../docs/design/boxxy/slice-1.md#the-slice-1-concepts)).
37
40
  - `conceptPins`, each concept's pin, which the build generates from the concept's source
@@ -1,2 +1,2 @@
1
1
  import type { Concept } from '@boxxy/model';
2
- export declare const deferConcept: Concept;
2
+ export declare const defer: Concept;
@@ -1,2 +1,2 @@
1
1
  import type { Concept } from '@boxxy/model';
2
- export declare const expediteConcept: Concept;
2
+ export declare const expedite: Concept;
@@ -1,2 +1,2 @@
1
1
  import type { Concept } from '@boxxy/model';
2
- export declare const poolConcept: Concept;
2
+ export declare const pool: Concept;
package/dist/decide.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { Decision, Frame, Pool, PoolName } from '@boxxy/model';
1
+ import type { Decision, Frame, Pool, PoolName, Snapshot } from '@boxxy/model';
2
2
  interface DecideInput {
3
3
  readonly frame: Frame;
4
4
  readonly pools: readonly Pool[];
5
- readonly freeSlots: Readonly<Record<PoolName, number>>;
5
+ readonly snapshot: Pick<Snapshot, 'limits' | 'wakes'>;
6
+ readonly backoff: readonly PoolName[];
6
7
  }
7
8
  export declare function decide(input: DecideInput): Decision;
8
9
  export {};
@@ -1,8 +1,8 @@
1
- import type { Fault, FrameItem, ItemId, LinkRow, PropagationLine, Resolved, SignalKey } from '@boxxy/model';
1
+ import type { Fault, ItemId, ItemView, PropagationLine, RelationRow, Resolved, SignalKey } from '@boxxy/model';
2
2
  interface PropagateInput {
3
3
  readonly lines: readonly PropagationLine[];
4
- readonly items: readonly FrameItem[];
5
- readonly links: readonly LinkRow[];
4
+ readonly items: readonly ItemView[];
5
+ readonly relations: readonly RelationRow[];
6
6
  readonly signals: ReadonlyMap<SignalKey, Resolved<'signal'>>;
7
7
  readonly values: ReadonlyMap<SignalKey, ReadonlyMap<ItemId, number>>;
8
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boxxy/planning",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "boxxy's planning: builds the frame from the plug-ins and picks the next work for each free slot",
5
5
  "homepage": "https://github.com/zgeoff/boxxy/tree/main/modules/planning#readme",
6
6
  "bugs": "https://github.com/zgeoff/boxxy/issues",
@@ -31,9 +31,9 @@
31
31
  "typecheck": "tsrs --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@boxxy/content-hash": "0.1.0",
35
- "@boxxy/model": "0.1.1",
36
- "@boxxy/rules": "0.1.0",
34
+ "@boxxy/content-hash": "0.1.3",
35
+ "@boxxy/model": "0.1.3",
36
+ "@boxxy/rules": "0.1.3",
37
37
  "tiny-invariant": "1.3.3",
38
38
  "zod": "4.6.7"
39
39
  },
@@ -7,8 +7,7 @@ import type {
7
7
  Hold,
8
8
  Instant,
9
9
  ItemId,
10
- ItemKindName,
11
- KindRules,
10
+ ItemView,
12
11
  Lift,
13
12
  LoadedRules,
14
13
  PlanContext,
@@ -29,13 +28,13 @@ interface BuildFrameInput {
29
28
  }
30
29
 
31
30
  export function buildFrame(input: BuildFrameInput): Frame {
32
- const items = collectFrameItems(input.snapshot, input.rules.kinds);
33
- const candidates = items.filter((item) => item.derived.state === 'ready');
31
+ const items = input.snapshot.items;
32
+ const candidates = collectCandidates(items);
34
33
 
35
34
  const buildContext = (values: FrameValues): PlanContext => ({
36
35
  now: input.now,
37
36
  levers: input.snapshot.levers,
38
- links: input.snapshot.links,
37
+ relations: input.snapshot.relations,
39
38
  values,
40
39
  });
41
40
 
@@ -48,7 +47,7 @@ export function buildFrame(input: BuildFrameInput): Frame {
48
47
  const propagated = propagate({
49
48
  lines: input.rules.propagation,
50
49
  items,
51
- links: input.snapshot.links,
50
+ relations: input.snapshot.relations,
52
51
  signals: input.rules.signals,
53
52
  values: new Map(carried.map((entry) => [entry.key, entry.values])),
54
53
  });
@@ -90,14 +89,17 @@ export function buildFrame(input: BuildFrameInput): Frame {
90
89
  };
91
90
  }
92
91
 
93
- function collectFrameItems(
94
- snapshot: Snapshot,
95
- kinds: ReadonlyMap<ItemKindName, KindRules>,
96
- ): FrameItem[] {
97
- return snapshot.items.flatMap((item) => {
98
- const phase = kinds.get(item.kind)?.pipeline.steps[0]?.phase;
92
+ function collectCandidates(items: readonly ItemView[]): FrameItem[] {
93
+ return items.flatMap((item) => {
94
+ if (item.derived.state !== 'ready') {
95
+ return [];
96
+ }
97
+
98
+ const position = item.position;
99
+
100
+ invariant(position.kind === 'phase', `ready item ${item.id} stands at no phase`);
99
101
 
100
- return phase === undefined ? [] : [{ ...item, phase }];
102
+ return [{ ...item, phase: position.phase, round: position.round }];
101
103
  });
102
104
  }
103
105
 
@@ -110,7 +112,7 @@ interface SignalValues {
110
112
  function collectSignalValues(
111
113
  key: SignalKey,
112
114
  signal: Resolved<'signal'>,
113
- items: readonly FrameItem[],
115
+ items: readonly ItemView[],
114
116
  context: PlanContext,
115
117
  ): SignalValues {
116
118
  const values = new Map<ItemId, number>();
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
 
5
5
  const expediteKey = signalKey('expedite', {});
6
6
 
7
- export const deferConcept: Concept = {
7
+ export const defer: Concept = {
8
8
  name: 'defer',
9
9
  version: 1,
10
10
  gates: [
@@ -2,7 +2,7 @@ import type { Concept } from '@boxxy/model';
2
2
  import { defineSignal } from '@boxxy/model';
3
3
  import { z } from 'zod';
4
4
 
5
- export const expediteConcept: Concept = {
5
+ export const expedite: Concept = {
6
6
  name: 'expedite',
7
7
  version: 1,
8
8
  signals: [
@@ -2,9 +2,9 @@ import type { Concept } from '@boxxy/model';
2
2
  import { defineSignal } from '@boxxy/model';
3
3
  import { z } from 'zod';
4
4
 
5
- export const poolConcept: Concept = {
5
+ export const pool: Concept = {
6
6
  name: 'pool',
7
- version: 1,
7
+ version: 2,
8
8
  signals: [
9
9
  defineSignal({
10
10
  name: 'oldest',
@@ -16,5 +16,8 @@ export const poolConcept: Concept = {
16
16
  { name: 'phase', value: (item) => item.phase },
17
17
  { name: 'kind', value: (item) => item.kind },
18
18
  { name: 'repo', value: (item) => item.repo },
19
+
20
+ // an item that sets no design mode runs in agent mode
21
+ { name: 'designMode', value: (item) => item.fields.designMode ?? 'agent' },
19
22
  ],
20
23
  };
package/src/concepts.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Concept } from '@boxxy/model';
2
- import { deferConcept } from './concepts/defer.ts';
3
- import { expediteConcept } from './concepts/expedite.ts';
4
- import { poolConcept } from './concepts/pool.ts';
2
+ import { defer } from './concepts/defer.ts';
3
+ import { expedite } from './concepts/expedite.ts';
4
+ import { pool } from './concepts/pool.ts';
5
5
 
6
- export const concepts: readonly Concept[] = [poolConcept, deferConcept, expediteConcept];
6
+ export const concepts: readonly Concept[] = [pool, defer, expedite];
package/src/decide.ts CHANGED
@@ -1,49 +1,165 @@
1
- import type { Decision, Frame, FrameItem, ItemId, Pick, Pool, PoolName } from '@boxxy/model';
1
+ import type {
2
+ Decision,
3
+ FieldValues,
4
+ Filter,
5
+ Frame,
6
+ FrameItem,
7
+ LimitFill,
8
+ LimitName,
9
+ Pool,
10
+ PoolName,
11
+ Snapshot,
12
+ StartPick,
13
+ Wait,
14
+ WaitReason,
15
+ WakePick,
16
+ } from '@boxxy/model';
2
17
  import { compareItemIDs } from './compare-item-ids.ts';
3
18
 
4
19
  interface DecideInput {
5
20
  readonly frame: Frame;
6
21
  readonly pools: readonly Pool[];
7
- readonly freeSlots: Readonly<Record<PoolName, number>>;
22
+ readonly snapshot: Pick<Snapshot, 'limits' | 'wakes'>;
23
+
24
+ // the pools whose capacity or usage-limit backoff runs at the claim's time
25
+ readonly backoff: readonly PoolName[];
8
26
  }
9
27
 
10
28
  export function decide(input: DecideInput): Decision {
11
- const frame = input.frame;
29
+ const seats = buildSeats(input.snapshot.limits);
12
30
 
13
- const picked = new Set<ItemId>();
31
+ const backoff = new Set(input.backoff);
14
32
 
15
- const picks: Pick[] = [];
33
+ const picks: (StartPick | WakePick)[] = [];
34
+ const waits: Wait[] = [];
16
35
 
17
- for (const pool of input.pools) {
18
- const free = input.freeSlots[pool.name] ?? 0;
36
+ for (const wake of input.snapshot.wakes) {
37
+ const subject = { kind: 'wake', attempt: wake.attempt } as const;
38
+ const taken = backoff.has(wake.pool) ? backoffOf(wake.pool) : seats.take(wake.fields);
19
39
 
20
- const ranked = frame.candidates
21
- .filter(
22
- (item) =>
23
- !picked.has(item.id) &&
24
- (frame.holds.get(item.id) ?? []).length === 0 &&
25
- isTakenBy(pool, frame.fields.get(item.id) ?? {}),
26
- )
27
- .toSorted((left, right) => compareCandidates(left, right, pool, frame));
40
+ if (taken.kind === 'taken') {
41
+ picks.push({ kind: 'wake', attempt: wake.attempt, limits: taken.limits });
42
+ } else {
43
+ waits.push({ subject, reason: taken.reason });
44
+ }
45
+ }
28
46
 
29
- for (const item of ranked.slice(0, free)) {
30
- picked.add(item.id);
31
- picks.push({ item: item.id, phase: item.phase, pool: pool.name });
47
+ const offered = collectOffers(input.frame, input.pools);
48
+
49
+ waits.push(...offered.unpooled);
50
+
51
+ for (const offer of offered.offers) {
52
+ const subject = { kind: 'item', item: offer.item.id } as const;
53
+ const taken = backoff.has(offer.pool) ? backoffOf(offer.pool) : seats.take(offer.fields);
54
+
55
+ if (taken.kind === 'taken') {
56
+ picks.push({
57
+ kind: 'start',
58
+ item: offer.item.id,
59
+ phase: offer.item.phase,
60
+ round: offer.item.round,
61
+ pool: offer.pool,
62
+ fields: offer.fields,
63
+ limits: taken.limits,
64
+ });
65
+ } else {
66
+ waits.push({ subject, reason: taken.reason });
32
67
  }
33
68
  }
34
69
 
35
- return { picks };
70
+ return { picks, waits };
71
+ }
72
+
73
+ type SeatTaking =
74
+ | { readonly kind: 'taken'; readonly limits: readonly LimitName[] }
75
+ | { readonly kind: 'refused'; readonly reason: WaitReason };
76
+
77
+ interface Seats {
78
+ take: (fields: FieldValues) => SeatTaking;
36
79
  }
37
80
 
38
- function isTakenBy(pool: Pool, fields: Readonly<Record<string, readonly string[]>>): boolean {
39
- return Object.entries(pool.takes).every(([field, accepted]) =>
81
+ // The free seats of each limit, counted down by each pick of this decision. A limit held over its
82
+ // max after a wake over the cap has fewer than none.
83
+ function buildSeats(limits: readonly LimitFill[]): Seats {
84
+ const free = new Map(limits.map((limit) => [limit.name, limit.max - limit.held]));
85
+
86
+ return {
87
+ take: (fields) => {
88
+ const counting = limits.filter((limit) => isMatch(limit.counts, fields));
89
+ const full = counting.find((limit) => (free.get(limit.name) ?? 0) <= 0);
90
+
91
+ if (full !== undefined) {
92
+ return { kind: 'refused', reason: { kind: 'limit-full', limit: full.name } };
93
+ }
94
+
95
+ for (const limit of counting) {
96
+ free.set(limit.name, (free.get(limit.name) ?? 0) - 1);
97
+ }
98
+
99
+ return { kind: 'taken', limits: counting.map((limit) => limit.name) };
100
+ },
101
+ };
102
+ }
103
+
104
+ // An item matches a filter when, for every field, one of its values is in the filter's list.
105
+ function isMatch(filter: Filter, fields: FieldValues): boolean {
106
+ return Object.entries(filter).every(([field, accepted]) =>
40
107
  (fields[field] ?? []).some((value) => accepted.includes(value)),
41
108
  );
42
109
  }
43
110
 
44
- function compareCandidates(left: FrameItem, right: FrameItem, pool: Pool, frame: Frame): number {
111
+ function backoffOf(pool: PoolName): SeatTaking {
112
+ return { kind: 'refused', reason: { kind: 'backoff', pool } };
113
+ }
114
+
115
+ interface Offer {
116
+ readonly item: FrameItem;
117
+ readonly pool: PoolName;
118
+ readonly fields: FieldValues;
119
+ }
120
+
121
+ interface Offers {
122
+ // in pool order, then by each pool's `order`
123
+ readonly offers: readonly Offer[];
124
+ readonly unpooled: readonly Wait[];
125
+ }
126
+
127
+ // Each candidate that no gate holds belongs to the first pool, in declared order, that takes it.
128
+ function collectOffers(frame: Frame, pools: readonly Pool[]): Offers {
129
+ const free = frame.candidates.filter((item) => (frame.holds.get(item.id) ?? []).length === 0);
130
+
131
+ const byPool = new Map<PoolName, FrameItem[]>(pools.map((pool) => [pool.name, []]));
132
+
133
+ const unpooled: Wait[] = [];
134
+
135
+ for (const item of free) {
136
+ const fields = frame.fields.get(item.id) ?? {};
137
+ const pool = pools.find((candidate) => isMatch(candidate.takes, fields));
138
+
139
+ if (pool === undefined) {
140
+ unpooled.push({ subject: { kind: 'item', item: item.id }, reason: { kind: 'no-pool' } });
141
+ } else {
142
+ byPool.get(pool.name)?.push(item);
143
+ }
144
+ }
145
+
146
+ const offers = pools.flatMap((pool) =>
147
+ (byPool.get(pool.name) ?? [])
148
+ .toSorted((left, right) => compareCandidates(left, right, pool, frame.values))
149
+ .map((item) => ({ item, pool: pool.name, fields: frame.fields.get(item.id) ?? {} })),
150
+ );
151
+
152
+ return { offers, unpooled };
153
+ }
154
+
155
+ function compareCandidates(
156
+ left: FrameItem,
157
+ right: FrameItem,
158
+ pool: Pool,
159
+ values: Frame['values'],
160
+ ): number {
45
161
  for (const key of pool.order) {
46
- const byItem = frame.values.get(key);
162
+ const byItem = values.get(key);
47
163
  const order = compareValues(byItem?.get(left.id), byItem?.get(right.id));
48
164
 
49
165
  if (order !== 0) {
@@ -8,7 +8,7 @@ export const conceptPins: ReadonlyMap<ConceptName, ConceptPin> = new Map([
8
8
  {
9
9
  ref: 'defer@1',
10
10
  sourceHash: contentHashSchema.parse(
11
- '465c5e5376b2de6814f08888cd479249e494d1986802aa6293e2495b88e846d9',
11
+ 'cbe173e108ca3b290f7f48ea3a4145b28f44611ae7f4a43d29430f08b88f46df',
12
12
  ),
13
13
  },
14
14
  ],
@@ -17,16 +17,16 @@ export const conceptPins: ReadonlyMap<ConceptName, ConceptPin> = new Map([
17
17
  {
18
18
  ref: 'expedite@1',
19
19
  sourceHash: contentHashSchema.parse(
20
- '672b566d996e4b8d1aeb6e9e768460726473d6f0181a0eedcf09494687679882',
20
+ 'f75e37b479e49da4e68aab6f3d8e9960b04608eb7a9fdd53d4349578e1c213d3',
21
21
  ),
22
22
  },
23
23
  ],
24
24
  [
25
25
  'pool',
26
26
  {
27
- ref: 'pool@1',
27
+ ref: 'pool@2',
28
28
  sourceHash: contentHashSchema.parse(
29
- '93bb6fefe3cf95fc8a15bbe9381733427ba34c2118626449c7828929f895b984',
29
+ '1631a22966bd50fa39a8acc79f242fb0ac6603ead15b8f4ca3907f5e2cdd1214',
30
30
  ),
31
31
  },
32
32
  ],
package/src/propagate.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type {
2
2
  Fault,
3
- FrameItem,
4
3
  ItemId,
5
- LinkRow,
4
+ ItemView,
6
5
  PropagationLine,
6
+ RelationRow,
7
7
  Resolved,
8
8
  SignalKey,
9
9
  } from '@boxxy/model';
@@ -14,8 +14,8 @@ import { formatFault } from './format-fault.ts';
14
14
 
15
15
  interface PropagateInput {
16
16
  readonly lines: readonly PropagationLine[];
17
- readonly items: readonly FrameItem[];
18
- readonly links: readonly LinkRow[];
17
+ readonly items: readonly ItemView[];
18
+ readonly relations: readonly RelationRow[];
19
19
  readonly signals: ReadonlyMap<SignalKey, Resolved<'signal'>>;
20
20
  readonly values: ReadonlyMap<SignalKey, ReadonlyMap<ItemId, number>>;
21
21
  }
@@ -44,7 +44,7 @@ export function propagate(input: PropagateInput): Propagated {
44
44
  try {
45
45
  const start = values.get(key) ?? new Map<ItemId, number>();
46
46
 
47
- values.set(key, carryLine(line, combine, start, { open, links: input.links }));
47
+ values.set(key, carryLine(line, combine, start, { open, relations: input.relations }));
48
48
  } catch (error) {
49
49
  faults.push({
50
50
  plugin: `propagation:${line.value}:${line.over}`,
@@ -70,7 +70,7 @@ function getCombine(signals: PropagateInput['signals'], key: SignalKey): Combine
70
70
 
71
71
  interface LineGraph {
72
72
  readonly open: ReadonlySet<ItemId>;
73
- readonly links: readonly LinkRow[];
73
+ readonly relations: readonly RelationRow[];
74
74
  }
75
75
 
76
76
  function carryLine(
@@ -79,7 +79,7 @@ function carryLine(
79
79
  start: ReadonlyMap<ItemId, number>,
80
80
  graph: LineGraph,
81
81
  ): Map<ItemId, number> {
82
- const next = collectNeighbours(line, graph.links);
82
+ const next = collectNeighbours(line, graph.relations);
83
83
 
84
84
  const carried = new Map(start);
85
85
 
@@ -112,13 +112,15 @@ function carryLine(
112
112
 
113
113
  function collectNeighbours(
114
114
  line: PropagationLine,
115
- links: readonly LinkRow[],
115
+ relations: readonly RelationRow[],
116
116
  ): Map<ItemId, ItemId[]> {
117
117
  const next = new Map<ItemId, ItemId[]>();
118
118
 
119
- for (const link of links.filter((candidate) => candidate.type === line.over)) {
119
+ for (const relation of relations.filter((candidate) => candidate.type === line.over)) {
120
120
  const [from, to] =
121
- line.toward === 'source' ? [link.target, link.source] : [link.source, link.target];
121
+ line.toward === 'source'
122
+ ? [relation.target, relation.source]
123
+ : [relation.source, relation.target];
122
124
 
123
125
  const neighbours = next.get(from) ?? [];
124
126
 
@@ -1,5 +1,6 @@
1
1
  import { contentHash } from '@boxxy/content-hash';
2
2
  import type { Concept, ConceptPin, JsonValue, LoadedRules } from '@boxxy/model';
3
+ import { buildMockPhaseKind } from '@boxxy/model/test-utils/build-mock-phase-kind';
3
4
  import { loadRules } from '@boxxy/rules';
4
5
  import invariant from 'tiny-invariant';
5
6
  import { concepts as builtInConcepts } from '../concepts.ts';
@@ -17,20 +18,14 @@ export function buildMockLoadedRules(input: MockRulesInput = {}): LoadedRules {
17
18
  const file = {
18
19
  version: 1,
19
20
  concepts: concepts.map((concept) => `${concept.name}@${concept.version}`),
20
- kinds: {
21
- 'core.issue': {
22
- pipeline: ['build'],
23
- maxAttempts: { build: 2 },
24
- recipes: { build: 'build-v1' },
25
- },
21
+ pipelines: {
22
+ standard: [{ phase: 'build', maxAttempts: 2, timeoutMinutes: 180, on: { built: 'next' } }],
26
23
  },
24
+ kinds: { 'core.issue': { pipeline: 'standard', recipes: { build: 'build-v1' } } },
25
+ relationTypes: ['blocks'],
26
+ limits: [{ name: 'workers', max: 7, counts: {} }],
27
27
  pools: input.pools ?? [
28
- {
29
- name: 'build',
30
- slots: 2,
31
- takes: { phase: 'build', kind: ['core.issue'] },
32
- order: ['oldest'],
33
- },
28
+ { name: 'build', takes: { phase: 'build', kind: ['core.issue'] }, order: ['oldest'] },
34
29
  ],
35
30
  lead: input.lead ?? ['expedite'],
36
31
  propagation: input.propagation ?? [{ value: 'expedite', over: 'blocks', toward: 'source' }],
@@ -41,8 +36,10 @@ export function buildMockLoadedRules(input: MockRulesInput = {}): LoadedRules {
41
36
  effort: 'high',
42
37
  prompt: 'prompts/build-v1.md',
43
38
  source: 'subscription',
39
+ skills: [],
44
40
  },
45
41
  },
42
+ sleep: { afterIdleMinutes: 30, afterQuietMinutes: 60, afterUnattendedMinutes: 240 },
46
43
  };
47
44
 
48
45
  const encoder = new TextEncoder();
@@ -68,6 +65,7 @@ export function buildMockLoadedRules(input: MockRulesInput = {}): LoadedRules {
68
65
  },
69
66
  concepts,
70
67
  pins: new Map(pins),
68
+ phases: [buildMockPhaseKind({ name: 'build', outcomes: ['built'] })],
71
69
  });
72
70
 
73
71
  invariant(