Skip to content

feat(schema): add phase 22 calculation engine - #23

Closed
cobmojo wants to merge 2 commits into
canaryfrom
codex/phase-22-calculation-engine
Closed

feat(schema): add phase 22 calculation engine#23
cobmojo wants to merge 2 commits into
canaryfrom
codex/phase-22-calculation-engine

Conversation

@cobmojo

@cobmojo cobmojo commented May 2, 2026

Copy link
Copy Markdown

Summary

  • Adds Phase 22 deterministic calculation primitives in @asym/pdf-template-schema.
  • Exports structured helpers for numeric aggregates, table totals, grouped subtotals, invoice totals, financial totals, and tax-deductible amounts.
  • Documents the Phase 22 contract, roadmap/OpenSpec state, decision log entry, and rollback guidance.

Validation

  • corepack pnpm --filter @asym/pdf-template-schema test - passed, 64 tests.
  • corepack pnpm --filter @asym/pdf-template-schema typecheck - passed.
  • corepack pnpm --filter @asym/pdf-template-schema build - passed.
  • corepack pnpm lint - passed with one existing warning in untouched apps/web/src/app/editor/editor-overrides.css.
  • corepack pnpm dlx @fission-ai/openspec@latest validate build-pdf-document-builder - passed.
  • corepack pnpm dlx @fission-ai/openspec@latest validate --all - passed.
  • git diff --check - passed with Windows LF-to-CRLF working-copy warnings only.

Earlier Phase 22 validation also passed renderer/editor package tests, typechecks, builds, @react-email/editor tests, export smoke, and full pnpm test before this PR branch was created.

Known gaps

  • Renderer visible total rows and summary blocks are intentionally deferred to Phase 23.
  • Calculations are pure helpers only; templates still do not evaluate arbitrary JavaScript.

Rollback

Revert this PR to remove the calculation source and tests, restore the schema export/maturity metadata, and undo the Phase 22 documentation/OpenSpec updates. Then rerun schema, renderer, editor, lint, and OpenSpec validation.

OpenSpec

Phase 22: Build Calculation Engine for Totals, Subtotals, and Grouping. Next phase entry point: Phase 23 exposes summary blocks and total rows.

Greptile Summary

This PR introduces the Phase 22 deterministic calculation engine in @asym/pdf-template-schema, adding BigInt-backed decimal helpers for numeric aggregates, table totals, grouped subtotals, invoice totals, financial totals, and tax-deductible amounts — all exported from the schema root with no new runtime dependencies.

Two correctness issues in calculateGroupedTableTotals need attention before callers rely on the public API:

  • Group diagnostic sourceIndex is group-relative, not source-array-relative. Diagnostics emitted inside a per-group calculateRowsAggregate call carry the row's index within the group slice, not within the original source array. Any UI or tool that reads group.diagnostics[].sourceIndex to highlight the offending row will silently point to the wrong row for any group that does not start at position 0.
  • grandTotal silently includes amounts from rows excluded from all groups. Rows with a missing/null group key are skipped during group assignment but are still passed to the grand-aggregate call, so grandTotal can exceed the sum of all group.total values without any diagnostic flagging the discrepancy.

Confidence Score: 4/5

Safe to merge with the two P1 issues tracked — the calculation engine is not yet wired into rendered output (Phase 23), but the public API contract should be correct before consumers depend on it.

Two P1 correctness issues in calculateGroupedTableTotals (wrong sourceIndex in group diagnostics; grandTotal may not equal sum of group totals when group keys are missing) cap the score at 4. No P0 issues found. The rest of the arithmetic logic, rounding, and edge-case handling is solid and well-tested.

packages/pdf-template-schema/src/calculations.ts — specifically the calculateGroupedTableTotals function (lines 348–382).

Important Files Changed

Filename Overview
packages/pdf-template-schema/src/calculations.ts New 1105-line calculation engine with BigInt decimal arithmetic. Two P1 issues in calculateGroupedTableTotals: group diagnostics report group-relative sourceIndex (not source-array-relative), and grandTotal can silently include amounts from rows excluded from all groups.
packages/pdf-template-schema/src/index.ts Exports all Phase 22 calculation types and functions from ./calculations; bumps maturity tag to phase-22-calculation-engine.
packages/pdf-template-schema/test/calculations.spec.ts 64 tests cover the main calculation paths; the grouped-totals diagnostic test omits sourceIndex assertion for non-first groups, leaving the group-relative index bug untested.
packages/pdf-template-schema/test/public-entry.spec.ts Updates public-entry smoke test to Phase 22 boundary and verifies calculateNumericAggregate is exported.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[calculateGroupedTableTotals] --> B[resolveCalculationRows\nfull source array]
    B --> C{each row}
    C -->|groupPath found| D[assign row to group bucket]
    C -->|groupPath missing| E[push missing_calculation_field diagnostic\nskip row]
    D --> F[calculateRowsAggregate\nper group subset]
    F --> G[group.diagnostics\nsourceIndex = group-relative ⚠️]
    B --> H[calculateRowsAggregate\nGRAND — ALL rows including skipped ⚠️]
    H --> I[grandTotal\nmay include skipped-row amounts]
    G --> J[CalculateGroupedTableTotalsResult]
    I --> J
Loading

Reviews (2): Last reviewed commit: "fix(schema): harden calculation diagnost..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Comment thread packages/pdf-template-schema/src/calculations.ts
Comment thread packages/pdf-template-schema/src/calculations.ts Outdated
Comment on lines +348 to +365
sourcePath: input.sourcePath,
valuePath: input.valuePath,
});

return {
diagnostics: aggregate.diagnostics,
key: groupKey,
label: groupKey,
total:
aggregate.value ?? createDecimalValue(zeroBigInt, precision.scale, 0),
};
});
const grandAggregate = calculateRowsAggregate({
operation: 'sum',
precision,
rows: rowsResult.rows,
sourcePath: input.sourcePath,
valuePath: input.valuePath,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 sourceIndex in group diagnostics is group-relative, not source-array-relative

calculateRowsAggregate is called with the per-group row subset, so the sourceIndex emitted in each group.diagnostics[i] is the index of the row within that group's slice, not within the original sourcePath array. Any caller that reads group.diagnostics[].sourceIndex to identify which source record caused the error will get the wrong row for every group that does not happen to start at position 0 in the original array.

Concretely: if the source array is [A, B, C, D] and group 2 contains [C, D] (original indices 2 and 3), a non-numeric value on D yields sourceIndex: 1 in the group diagnostic, not sourceIndex: 3. The test for the "Outreach" group deliberately omits the sourceIndex assertion, confirming the value is incorrect.

Fix: track original indices when building group buckets and pass them through to calculateRowsAggregate so diagnostics carry the source-array position.

Comment on lines +366 to +382
});

diagnostics.push(...grandAggregate.diagnostics);

return {
diagnostics,
grandTotal:
grandAggregate.value ??
createDecimalValue(zeroBigInt, precision.scale, 0),
groups: calculatedGroups,
};
}

export function calculateInvoiceTotals(
input: CalculateInvoiceTotalsInput,
): CalculateInvoiceTotalsResult {
const precision = normalizePrecision(input.precision);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 grandTotal silently includes amounts from rows excluded from all groups

Rows whose groupPath value is null, undefined, or missing are skipped during group assignment (a missing_calculation_field diagnostic is pushed and return is called), so they contribute to no group's total. However, grandAggregate runs unconditionally on rowsResult.rows — the full unfiltered slice — meaning those same rows' valuePath amounts are summed into grandTotal.

The result: when any row lacks a group key, grandTotal > sum(group.total). A caller doing financial reconciliation will silently find a discrepancy without any diagnostic pointing at the mismatch.

Fix options:

  1. Build grandAggregate from only the rows successfully assigned to a group.
  2. Document and test the current semantics explicitly and add a test asserting the discrepancy when a group-key is missing.

@II-ricky-bobby-II

Copy link
Copy Markdown

Closing this as a stale duplicate of #24. Both PRs shared the same original Phase 22 commits, and #24 now carries the final grouped-calculation fixes and updated validation.

@II-ricky-bobby-II
II-ricky-bobby-II deleted the codex/phase-22-calculation-engine branch May 15, 2026 06:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants