@hyperscale0/udl 1.0.125 → 1.0.130

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/src/finance.ts CHANGED
@@ -98,9 +98,42 @@ export function analyzeInstrumentFinance(
98
98
  const problems: FinanceIssue[] = [];
99
99
  const report = (message: string) =>
100
100
  problems.push({ path: ".actions", message });
101
+ // A field some action sets is a free symbol: its default no longer holds.
102
+ const assigned = new Set(
103
+ Object.values(instrument.actions).flatMap((action) =>
104
+ Object.keys(action.set ?? {}).map((key) => `self.${key}`),
105
+ ),
106
+ );
107
+ const reads = (calc: object): string[] =>
108
+ JSON.stringify(calc)
109
+ .match(/"field":"[^"]+"/g)
110
+ ?.map((match) => match.slice(9, -1)) ?? [];
111
+ // Calculated fields downstream of the seeds, following opaque and linear calculations.
112
+ const downstream = (seeds: (field: string) => boolean): Set<string> => {
113
+ const found = new Set<string>();
114
+ for (let grew = true; grew;) {
115
+ grew = false;
116
+ for (const calc of instrument.calculate) {
117
+ const target = `self.${calc.target}`;
118
+ if (found.has(target)) continue;
119
+ if (reads(calc).some((field) => seeds(field) || found.has(field))) {
120
+ found.add(target);
121
+ grew = true;
122
+ }
123
+ }
124
+ }
125
+ return found;
126
+ };
127
+ const balanceBound = downstream(
128
+ (field) => field.endsWith(".balance") || field.endsWith(".reserved"),
129
+ );
101
130
  const equations = new Map<string, Sum>();
102
131
  for (const field of instrument.fields)
103
- if (field.type === "money" && field.value !== undefined)
132
+ if (
133
+ field.type === "money" &&
134
+ field.value !== undefined &&
135
+ !assigned.has(`self.${field.name}`)
136
+ )
104
137
  equations.set(
105
138
  `self.${field.name}`,
106
139
  new Map([["#", BigInt(field.value)]]),
@@ -146,9 +179,65 @@ export function analyzeInstrumentFinance(
146
179
  }
147
180
  return result!;
148
181
  }
182
+ // A top-level create admits the snapshot it reads; an invoked child create
183
+ // reads its parent's live subject but keeps the parent's snapshot.
184
+ const invokedCreate = (document?.instruments ?? []).some((owner) =>
185
+ Object.values(owner.actions).some((candidate) =>
186
+ (candidate.invoke ?? []).some(
187
+ (call) =>
188
+ "instrument" in call &&
189
+ call.instrument === instrument.id &&
190
+ call.action === "create",
191
+ ),
192
+ ),
193
+ );
149
194
  let invocation = 0;
150
195
  const apply = (state: State, action: UdlAction, name: string): void => {
151
196
  const scope = `${name}#${invocation++}`;
197
+ // An unknown balance becomes one opening symbol, so a whole-balance move cancels it.
198
+ const opening = `${scope}:opening:`;
199
+ for (const [account, balance] of state.balances)
200
+ if (balance === null)
201
+ state.balances.set(account, new Map([[`${opening}${account}`, 1n]]));
202
+ const start = new Map(state.balances);
203
+ // Moves read set fields after the set, so they get this invocation's symbol.
204
+ const changed = new Set(
205
+ Object.keys(action.set ?? {}).map((key) => `self.${key}`),
206
+ );
207
+ const stale = downstream((field) => changed.has(field));
208
+ let fresh = new Set<string>();
209
+ // An action that declares subject fields reads the live object, not the agreement.
210
+ const declared = new Set(
211
+ name === "create" && !invokedCreate
212
+ ? []
213
+ : (action.subject?.requirements ?? []).map(
214
+ (entry) => `subject.${entry.field.name}`,
215
+ ),
216
+ );
217
+ const live = new Set([
218
+ ...declared,
219
+ ...downstream((field) => declared.has(field)),
220
+ ]);
221
+ let reading = 0;
222
+ const external = (field: string) =>
223
+ /\.(balance|reserved)$/.test(field) && !owned.has(field.slice(0, -8));
224
+ const resolve = (sum: Sum): Balance => {
225
+ let result: Balance = new Map();
226
+ for (const [key, coefficient] of sum) {
227
+ const account = key.slice(0, -8);
228
+ const scoped =
229
+ balanceBound.has(key) ||
230
+ fresh.has(key) ||
231
+ live.has(key) ||
232
+ external(key);
233
+ const term: Balance =
234
+ key.endsWith(".balance") && owned.has(account)
235
+ ? (start.get(account) ?? null)
236
+ : new Map([[scoped ? `${scope}:${key}` : key, 1n]]);
237
+ result = add(result, term, coefficient);
238
+ }
239
+ return result;
240
+ };
152
241
  const calculated = new Map<string, Balance>();
153
242
  const local = new Map(
154
243
  (action.calculate ?? []).map((c) => [`self.${c.target}`, c]),
@@ -161,6 +250,9 @@ export function analyzeInstrumentFinance(
161
250
  owned.has(value.field.slice(0, -8))
162
251
  )
163
252
  return state.balances.get(value.field.slice(0, -8)) ?? null;
253
+ // Another account's balance moves between reads, so each read is its own symbol.
254
+ if (external(value.field))
255
+ return new Map([[`${scope}:${reading++}:${value.field}`, 1n]]);
164
256
  const calculation = local.get(value.field);
165
257
  if (calculation && !seen.has(value.field)) {
166
258
  const next = new Set([...seen, value.field]);
@@ -177,7 +269,7 @@ export function analyzeInstrumentFinance(
177
269
  return new Map([[`${scope}:${value.field}`, 1n]]);
178
270
  }
179
271
  }
180
- return expand(raw(value, scope));
272
+ return resolve(expand(raw(value, scope)));
181
273
  };
182
274
  for (const requirement of action.requires)
183
275
  if (requirement.kind === "compare" && requirement.operator === "==") {
@@ -217,6 +309,7 @@ export function analyzeInstrumentFinance(
217
309
  ),
218
310
  );
219
311
  };
312
+ fresh = new Set([...changed, ...stale]);
220
313
  for (const move of action.moves) {
221
314
  if ("amount" in move) {
222
315
  const value = amount(move.amount);
@@ -240,13 +333,24 @@ export function analyzeInstrumentFinance(
240
333
  state.holds.delete(move.transfer);
241
334
  }
242
335
  }
243
- if (
244
- Object.keys(action.set ?? {}).some((key) =>
245
- instrument.fields.some((f) => f.name === key && f.type === "money"),
246
- )
247
- ) {
248
- for (const account of owned) state.balances.set(account, null);
249
- }
336
+ // Old values of set fields are gone; this invocation's values become current.
337
+ const settle = (sum: Balance): Balance =>
338
+ sum === null ||
339
+ [...sum.keys()].some((key) => key.startsWith(opening) || fresh.has(key))
340
+ ? null
341
+ : new Map(
342
+ [...sum].map(([key, value]) => {
343
+ const field = key.slice(scope.length + 1);
344
+ return key.startsWith(`${scope}:`) &&
345
+ fresh.has(field) &&
346
+ !balanceBound.has(field)
347
+ ? [field, value]
348
+ : [key, value];
349
+ }),
350
+ );
351
+ for (const [account, balance] of state.balances)
352
+ state.balances.set(account, settle(balance));
353
+ for (const hold of state.holds.values()) hold.amount = settle(hold.amount);
250
354
  };
251
355
  const initial: State = {
252
356
  status: instrument.lifecycle.initial,
@@ -24,8 +24,9 @@ export function presentationLabel(name: string): string {
24
24
  }
25
25
  /**
26
26
  * A state is terminal when no transition lists it in `from`, so nothing can
27
- * leave it. Every "is this finished" answer (lifecycle metadata, the close
28
- * guard, open records) reads this rule.
27
+ * leave it. Lifecycle metadata reads this rule. The close guard and open
28
+ * records start from it and also drop exits a parent runs or a passed
29
+ * deadline has shut (the engine's `openInstanceFilter`).
29
30
  */
30
31
  export function isTerminalState(
31
32
  instrument: UdlInstrument,
package/src/schema.ts CHANGED
@@ -258,6 +258,10 @@ const selection = z.strictObject({
258
258
  window: z
259
259
  .strictObject({ field: name, milliseconds: integer.positive() })
260
260
  .optional(),
261
+ /** Rows whose half-open [start, end) period meets [from, until). */
262
+ overlaps: z
263
+ .strictObject({ start: name, end: name, from: value, until: value })
264
+ .optional(),
261
265
  where: z
262
266
  .record(path, value)
263
267
  .refine(
@@ -318,6 +322,15 @@ export const udlCalculationSchema = z.discriminatedUnion("op", [
318
322
  divisor: value,
319
323
  rounding: z.literal("floor"),
320
324
  }),
325
+ z.strictObject({
326
+ target: name,
327
+ op: z.literal("annuity"),
328
+ amount: value,
329
+ rate: value,
330
+ periods: value,
331
+ position: value,
332
+ part: z.enum(["principal", "interest"]),
333
+ }),
321
334
  z.strictObject({
322
335
  target: name,
323
336
  op: z.literal("shift"),
package/src/validation.ts CHANGED
@@ -885,6 +885,17 @@ export function validateUdl(value: unknown): UdlValidationResult {
885
885
  where,
886
886
  "selection window requires a date field on every selected instrument",
887
887
  );
888
+ if (
889
+ selection.overlaps &&
890
+ [selection.overlaps.start, selection.overlaps.end].some(
891
+ (name) =>
892
+ target.fields.find((f) => f.name === name)?.type !== "date",
893
+ )
894
+ )
895
+ add(
896
+ where,
897
+ "selection overlaps requires start and end date fields on every selected instrument",
898
+ );
888
899
  const reference = target.fields.find(
889
900
  (f) => f.name === selection.reference,
890
901
  );
@@ -917,6 +928,10 @@ export function validateUdl(value: unknown): UdlValidationResult {
917
928
  } else checkValue(value, selected.type, where, input);
918
929
  }
919
930
  }
931
+ if (selection.overlaps) {
932
+ checkValue(selection.overlaps.from, "date", where, input);
933
+ checkValue(selection.overlaps.until, "date", where, input);
934
+ }
920
935
  const first = targets[0];
921
936
  if (!first) return;
922
937
  return {
@@ -1059,6 +1074,13 @@ export function validateUdl(value: unknown): UdlValidationResult {
1059
1074
  "money multiplier must be nonnegative and divisor must be positive",
1060
1075
  );
1061
1076
  }
1077
+ if (c.op === "annuity") {
1078
+ money(c.amount);
1079
+ checkValue(c.rate, "percent", where, input, act);
1080
+ checkValue(c.periods, "integer", where, input, act);
1081
+ checkValue(c.position, "integer", where, input, act);
1082
+ operands.push(c.rate, c.periods, c.position);
1083
+ }
1062
1084
  if (c.op === "shift") {
1063
1085
  checkValue(c.date, "date", where, input, act);
1064
1086
  checkValue(