@hyperscale0/udl 2.1.0 → 2.3.0

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/validation.ts CHANGED
@@ -19,11 +19,14 @@ import {
19
19
  type UdlInstrument,
20
20
  type UdlStep,
21
21
  type UdlAction,
22
+ type UdlPieceStageStage,
22
23
  } from "./schema.js";
23
24
  import { fixedIsoDurationMs } from "./duration.js";
24
25
  import {
25
26
  analyzeInstrumentFinance,
26
27
  financeAdmissionProblem,
28
+ type FinanceIssue,
29
+ type FinanceOptions,
27
30
  } from "./finance.js";
28
31
  import { UDL_LIMITS } from "./limits.js";
29
32
  import {
@@ -1365,90 +1368,366 @@ function validateInstrument(
1365
1368
  }
1366
1369
  }
1367
1370
 
1368
- // Multi-variant finance oracle check across action plan combinations
1371
+ for (const finIssue of instrumentFinanceIssues(instrument, {
1372
+ plans: planResolution.plans,
1373
+ })) {
1374
+ add([...base, ...finIssue.path], finIssue.message, finIssue.code);
1375
+ }
1376
+ validateAggregates(instrument, base, instruments, references, add);
1377
+ }
1378
+
1379
+ /**
1380
+ * The finance oracle over an instrument's resolved action plans, with paths
1381
+ * rooted at the instrument. A piece-plan instrument is unfolded over the
1382
+ * runtime's piece progress; any other instrument runs once per action plan
1383
+ * combination. Contract-side callers pass the UDL projection of a blueprint
1384
+ * definition so both boundaries prove the same machine.
1385
+ */
1386
+ export function instrumentFinanceIssues(
1387
+ instrument: UdlInstrument,
1388
+ options: FinanceOptions & {
1389
+ readonly plans?: readonly ResolvedActionPlan[];
1390
+ } = {},
1391
+ ): readonly FinanceIssue[] {
1392
+ const plans = options.plans ?? resolveUdlActionPlans(instrument).plans;
1393
+ const financeOptions: FinanceOptions =
1394
+ options.penaltyMayBeNonzero === undefined
1395
+ ? {}
1396
+ : { penaltyMayBeNonzero: options.penaltyMayBeNonzero };
1397
+ const issues: FinanceIssue[] = [];
1398
+ const seen = new Set<string>();
1399
+ const add = (
1400
+ path: readonly PropertyKey[],
1401
+ message: string,
1402
+ code: UdlIssueCode,
1403
+ ): void => {
1404
+ const key = `${code}:${path.join(".")}:${message}`;
1405
+ if (seen.has(key)) return;
1406
+ seen.add(key);
1407
+ issues.push({ code, message, path });
1408
+ };
1409
+
1410
+ const expansion = instrument.piecePlan
1411
+ ? expandPieceProgress(instrument, plans)
1412
+ : undefined;
1413
+ if (expansion === "bound") {
1414
+ add(
1415
+ ["actions"],
1416
+ `piece progress expansion exceeds reachable variant bound of ${UDL_LIMITS.maxActionExpansion}`,
1417
+ "UDL2010",
1418
+ );
1419
+ return issues;
1420
+ }
1421
+ if (expansion) {
1422
+ for (const finIssue of analyzeInstrumentFinance(
1423
+ expansion.instrument,
1424
+ financeOptions,
1425
+ )) {
1426
+ const message = expansion.displayNames.reduce(
1427
+ (text, [expanded, display]) => text.replaceAll(expanded, display),
1428
+ finIssue.message,
1429
+ );
1430
+ add(originFinancePath(expansion, finIssue.path), message, finIssue.code);
1431
+ }
1432
+ return issues;
1433
+ }
1434
+
1369
1435
  const actionsWithPlans = Object.keys(instrument.actions).filter((aName) =>
1370
- planResolution.plans.some((p) => p.action === aName),
1436
+ plans.some((p) => p.action === aName),
1371
1437
  );
1372
-
1373
1438
  let totalCombinations = 1;
1374
1439
  const actionPlanMap: Record<string, ResolvedActionPlan[]> = {};
1375
1440
  for (const aName of actionsWithPlans) {
1376
- const actionPlans = planResolution.plans.filter((p) => p.action === aName);
1441
+ const actionPlans = plans.filter((p) => p.action === aName);
1377
1442
  actionPlanMap[aName] = actionPlans;
1378
1443
  totalCombinations *= actionPlans.length;
1379
1444
  }
1380
-
1381
1445
  if (
1382
1446
  actionsWithPlans.length > 0 &&
1383
1447
  totalCombinations > UDL_LIMITS.maxActionExpansion
1384
1448
  ) {
1385
1449
  add(
1386
- [...base, "actions"],
1450
+ ["actions"],
1387
1451
  `variant expansion exceeds combination bound of ${UDL_LIMITS.maxActionExpansion} (${totalCombinations} combinations)`,
1388
1452
  "UDL2010",
1389
1453
  );
1390
- } else {
1391
- const generateCombos = (
1392
- keys: string[],
1393
- ): Record<string, ResolvedActionPlan>[] => {
1394
- if (keys.length === 0) return [{}];
1395
- const [first, ...rest] = keys;
1396
- const restCombos = generateCombos(rest);
1397
- const result: Record<string, ResolvedActionPlan>[] = [];
1398
- for (const plan of actionPlanMap[first!]!) {
1399
- for (const combo of restCombos) {
1400
- result.push({ ...combo, [first!]: plan });
1401
- }
1454
+ return issues;
1455
+ }
1456
+ const generateCombos = (
1457
+ keys: string[],
1458
+ ): Record<string, ResolvedActionPlan>[] => {
1459
+ if (keys.length === 0) return [{}];
1460
+ const [first, ...rest] = keys;
1461
+ const restCombos = generateCombos(rest);
1462
+ const result: Record<string, ResolvedActionPlan>[] = [];
1463
+ for (const plan of actionPlanMap[first!]!) {
1464
+ for (const combo of restCombos) {
1465
+ result.push({ ...combo, [first!]: plan });
1402
1466
  }
1403
- return result;
1404
- };
1467
+ }
1468
+ return result;
1469
+ };
1470
+ const combinations =
1471
+ actionsWithPlans.length > 0 ? generateCombos(actionsWithPlans) : [{}];
1472
+ for (const combo of combinations) {
1473
+ const expandedActions: Record<string, UdlAction> = {};
1474
+ for (const [aName, aDef] of Object.entries(instrument.actions)) {
1475
+ expandedActions[aName] = planExpandedAction(aDef, combo[aName]);
1476
+ }
1477
+ for (const finIssue of analyzeInstrumentFinance(
1478
+ { ...instrument, actions: expandedActions },
1479
+ financeOptions,
1480
+ )) {
1481
+ add(finIssue.path, finIssue.message, finIssue.code);
1482
+ }
1483
+ }
1484
+ return issues;
1485
+ }
1486
+
1487
+ /**
1488
+ * Only an action that moves money through calls is replaced by its expanded
1489
+ * leaves. Authored moves and steps always reach the oracle.
1490
+ */
1491
+ function planExpandedAction(
1492
+ definition: UdlAction,
1493
+ plan: ResolvedActionPlan | undefined,
1494
+ ): UdlAction {
1495
+ if (!plan || (definition.calls?.length ?? 0) === 0) return definition;
1496
+ const steps: UdlStep[] = [];
1497
+ const moves: UdlMove[] = [];
1498
+ for (const leaf of plan.leaves) {
1499
+ if ("key" in leaf.step) {
1500
+ moves.push(leaf.step as UdlMove);
1501
+ } else {
1502
+ steps.push(leaf.step as UdlStep);
1503
+ }
1504
+ }
1505
+ return { ...definition, moves, steps };
1506
+ }
1405
1507
 
1406
- const combinations =
1407
- actionsWithPlans.length > 0 ? generateCombos(actionsWithPlans) : [{}];
1408
- const seenFinanceIssues = new Set<string>();
1508
+ interface PieceProgress {
1509
+ readonly funded: readonly string[];
1510
+ readonly consumed: readonly string[];
1511
+ }
1409
1512
 
1410
- for (const combo of combinations) {
1411
- const expandedActions: Record<
1412
- string,
1413
- (typeof instrument.actions)[string]
1414
- > = {};
1415
- for (const [aName, aDef] of Object.entries(instrument.actions)) {
1416
- const plan = combo[aName];
1417
- // Only an action that moves money through calls is replaced by its
1418
- // expanded leaves. Authored moves and steps always reach the oracle.
1419
- if (plan && (aDef.calls?.length ?? 0) > 0) {
1420
- const steps: UdlStep[] = [];
1421
- const moves: UdlMove[] = [];
1422
- for (const leaf of plan.leaves) {
1423
- if ("key" in leaf.step) {
1424
- moves.push(leaf.step as UdlMove);
1425
- } else {
1426
- steps.push(leaf.step as UdlStep);
1427
- }
1428
- }
1429
- expandedActions[aName] = {
1430
- ...aDef,
1431
- moves,
1432
- steps,
1433
- };
1434
- } else {
1435
- expandedActions[aName] = aDef;
1436
- }
1513
+ interface PieceProgressExpansion {
1514
+ readonly instrument: UdlInstrument;
1515
+ /** Expanded action name to display name, longest first. */
1516
+ readonly displayNames: readonly (readonly [string, string])[];
1517
+ readonly actionOrigins: ReadonlyMap<
1518
+ string,
1519
+ {
1520
+ readonly action: string;
1521
+ readonly leafOrigins?: readonly (readonly string[])[];
1522
+ }
1523
+ >;
1524
+ readonly stateOrigins: ReadonlyMap<string, string>;
1525
+ readonly originStates: readonly string[];
1526
+ }
1527
+
1528
+ function progressKey(progress: PieceProgress): string {
1529
+ return `${progress.funded.join(",")}|${progress.consumed.join(",")}`;
1530
+ }
1531
+
1532
+ /** Mirrors eligiblePieces in the engine's piece-plan dispatcher. */
1533
+ function eligiblePieces(
1534
+ plan: NonNullable<UdlInstrument["piecePlan"]>,
1535
+ stage: UdlPieceStageStage,
1536
+ progress: PieceProgress,
1537
+ ): readonly string[] {
1538
+ return plan[`${stage}_order`].filter((id) =>
1539
+ stage === "fund"
1540
+ ? !progress.funded.includes(id)
1541
+ : progress.funded.includes(id) && !progress.consumed.includes(id),
1542
+ );
1543
+ }
1544
+
1545
+ /**
1546
+ * Unfolds a piece-plan instrument over the runtime's piece progress so the
1547
+ * ordinary lifecycle oracle sees exactly the states the dispatcher admits: a
1548
+ * piece-stage action moves the next eligible piece of its stage order, the
1549
+ * lifecycle state is retained until the stage's last piece moves, funding
1550
+ * cannot resume once a piece has left escrow, and an action gated on a drained
1551
+ * account is closed while a funded piece is still held. Every expanded state is
1552
+ * one (lifecycle state, progress) pair; expanded action names carry the piece
1553
+ * and the source state so each has exactly one transition. A quote-commit pair
1554
+ * is not carried through the expansion.
1555
+ */
1556
+ function expandPieceProgress(
1557
+ instrument: UdlInstrument,
1558
+ plans: readonly ResolvedActionPlan[],
1559
+ ): PieceProgressExpansion | "bound" | undefined {
1560
+ const plan = instrument.piecePlan;
1561
+ if (!plan) return undefined;
1562
+ const stateName = (state: string, progress: PieceProgress): string =>
1563
+ `${state}#${progressKey(progress)}`;
1564
+ const initialProgress: PieceProgress = { funded: [], consumed: [] };
1565
+ const stateOrigins = new Map<string, string>();
1566
+ const actionOrigins = new Map<
1567
+ string,
1568
+ { action: string; leafOrigins?: readonly (readonly string[])[] }
1569
+ >();
1570
+ const displayNames: [string, string][] = [];
1571
+ const actions: Record<string, UdlAction> = {};
1572
+ const transitions: Record<string, { from: string[]; to: string }> = {};
1573
+ const pending: { state: string; progress: PieceProgress }[] = [
1574
+ { state: instrument.lifecycle.initial, progress: initialProgress },
1575
+ ];
1576
+ stateOrigins.set(
1577
+ stateName(instrument.lifecycle.initial, initialProgress),
1578
+ instrument.lifecycle.initial,
1579
+ );
1580
+
1581
+ const visit = (state: string, progress: PieceProgress): void => {
1582
+ const name = stateName(state, progress);
1583
+ if (stateOrigins.has(name)) return;
1584
+ stateOrigins.set(name, state);
1585
+ pending.push({ state, progress });
1586
+ };
1587
+
1588
+ while (pending.length > 0) {
1589
+ const { state, progress } = pending.shift()!;
1590
+ if (stateOrigins.size > UDL_LIMITS.maxActionExpansion) return "bound";
1591
+ const held = progress.funded.filter(
1592
+ (id) => !progress.consumed.includes(id),
1593
+ );
1594
+ for (const [actionName, transition] of Object.entries(
1595
+ instrument.lifecycle.transitions,
1596
+ )) {
1597
+ if (!transition.from.includes(state)) continue;
1598
+ const definition = instrument.actions[actionName];
1599
+ if (!definition) continue;
1600
+ if (definition.requiresDrainedAccount && held.length > 0) continue;
1601
+ const stage = definition.pieceStage;
1602
+ if (!stage) {
1603
+ const expanded = `${actionName}#${state}#${progressKey(progress)}`;
1604
+ const variant = plans.find(
1605
+ (candidate) => candidate.action === actionName,
1606
+ );
1607
+ actions[expanded] = planExpandedAction(definition, variant);
1608
+ transitions[expanded] = {
1609
+ from: [stateName(state, progress)],
1610
+ to: stateName(transition.to, progress),
1611
+ };
1612
+ actionOrigins.set(expanded, {
1613
+ action: actionName,
1614
+ ...(variant && (definition.calls?.length ?? 0) > 0
1615
+ ? { leafOrigins: variant.leaves.map((leaf) => leaf.originPath) }
1616
+ : {}),
1617
+ });
1618
+ displayNames.push([expanded, actionName]);
1619
+ visit(transition.to, progress);
1620
+ continue;
1437
1621
  }
1438
- const financeInstrument = {
1439
- ...instrument,
1440
- actions: expandedActions,
1622
+ if (stage.plan !== plan.id) continue;
1623
+ if (stage.stage === "fund" && progress.consumed.length > 0) continue;
1624
+ const eligible = eligiblePieces(plan, stage.stage, progress);
1625
+ const pieceId = eligible[0];
1626
+ if (pieceId === undefined) continue;
1627
+ const variant = plans.find(
1628
+ (candidate) =>
1629
+ candidate.action === actionName && candidate.pieceId === pieceId,
1630
+ );
1631
+ if (!variant) continue;
1632
+ const next: PieceProgress =
1633
+ stage.stage === "fund"
1634
+ ? {
1635
+ funded: [...progress.funded, pieceId],
1636
+ consumed: progress.consumed,
1637
+ }
1638
+ : {
1639
+ funded: progress.funded,
1640
+ consumed: [...progress.consumed, pieceId],
1641
+ };
1642
+ const stageComplete =
1643
+ eligiblePieces(plan, stage.stage, next).length === 0;
1644
+ const target = stageComplete ? transition.to : state;
1645
+ const expanded = `${actionName}@${pieceId}#${state}#${progressKey(progress)}`;
1646
+ actions[expanded] = planExpandedAction(definition, variant);
1647
+ transitions[expanded] = {
1648
+ from: [stateName(state, progress)],
1649
+ to: stateName(target, next),
1441
1650
  };
1442
- for (const finIssue of analyzeInstrumentFinance(financeInstrument)) {
1443
- const issueKey = `${finIssue.code}:${finIssue.path.join(".")}:${finIssue.message}`;
1444
- if (!seenFinanceIssues.has(issueKey)) {
1445
- seenFinanceIssues.add(issueKey);
1446
- add([...base, ...finIssue.path], finIssue.message, finIssue.code);
1447
- }
1448
- }
1651
+ actionOrigins.set(expanded, {
1652
+ action: actionName,
1653
+ leafOrigins: variant.leaves.map((leaf) => leaf.originPath),
1654
+ });
1655
+ displayNames.push([expanded, `${actionName}[${pieceId}]`]);
1656
+ visit(target, next);
1449
1657
  }
1450
1658
  }
1451
- validateAggregates(instrument, base, instruments, references, add);
1659
+
1660
+ for (const [actionName, definition] of Object.entries(instrument.actions)) {
1661
+ if (Object.hasOwn(instrument.lifecycle.transitions, actionName)) continue;
1662
+ actions[actionName] = planExpandedAction(
1663
+ definition,
1664
+ plans.find((candidate) => candidate.action === actionName),
1665
+ );
1666
+ actionOrigins.set(actionName, { action: actionName });
1667
+ }
1668
+
1669
+ return {
1670
+ instrument: {
1671
+ ...instrument,
1672
+ actions,
1673
+ lifecycle: {
1674
+ initial: stateName(instrument.lifecycle.initial, initialProgress),
1675
+ states: [...stateOrigins.keys()],
1676
+ transitions,
1677
+ },
1678
+ },
1679
+ displayNames: [
1680
+ ...displayNames,
1681
+ ...[...stateOrigins.entries()].map(
1682
+ ([expanded, origin]): [string, string] => [expanded, origin],
1683
+ ),
1684
+ ].sort((left, right) => right[0].length - left[0].length),
1685
+ actionOrigins,
1686
+ stateOrigins,
1687
+ originStates: instrument.lifecycle.states,
1688
+ };
1689
+ }
1690
+
1691
+ function originFinancePath(
1692
+ expansion: PieceProgressExpansion,
1693
+ path: readonly PropertyKey[],
1694
+ ): readonly PropertyKey[] {
1695
+ const [head, second, third, fourth, ...rest] = path;
1696
+ if (
1697
+ head === "lifecycle" &&
1698
+ second === "states" &&
1699
+ typeof third === "number"
1700
+ ) {
1701
+ const expandedState = expansion.instrument.lifecycle.states[third];
1702
+ const origin =
1703
+ expandedState === undefined
1704
+ ? undefined
1705
+ : expansion.stateOrigins.get(expandedState);
1706
+ const index =
1707
+ origin === undefined ? -1 : expansion.originStates.indexOf(origin);
1708
+ return index >= 0
1709
+ ? ["lifecycle", "states", index]
1710
+ : ["lifecycle", "states"];
1711
+ }
1712
+ if (head === "actions" && typeof second === "string") {
1713
+ const origin = expansion.actionOrigins.get(second);
1714
+ if (!origin) return path;
1715
+ if (
1716
+ third === "moves" &&
1717
+ typeof fourth === "number" &&
1718
+ origin.leafOrigins?.[fourth]
1719
+ ) {
1720
+ return ["actions", ...origin.leafOrigins[fourth], ...rest];
1721
+ }
1722
+ return [
1723
+ "actions",
1724
+ origin.action,
1725
+ ...(third === undefined ? [] : [third]),
1726
+ ...(fourth === undefined ? [] : [fourth]),
1727
+ ...rest,
1728
+ ];
1729
+ }
1730
+ return path;
1452
1731
  }
1453
1732
 
1454
1733
  function validatePiecePlan(
@@ -4455,7 +4734,7 @@ function referencedPathDeclared(target: UdlInstrument, path: string): boolean {
4455
4734
  function validateStep(
4456
4735
  instrument: UdlInstrument,
4457
4736
  action: UdlAction,
4458
- step: UdlStep | UdlMove,
4737
+ step: ResolvedActionPlan["leaves"][number]["step"],
4459
4738
  base: readonly PropertyKey[],
4460
4739
  add: AddIssue,
4461
4740
  ): void {