diff --git a/docs/rendering.md b/docs/rendering.md
index fe90b8d..c137fb4 100644
--- a/docs/rendering.md
+++ b/docs/rendering.md
@@ -89,18 +89,29 @@ Version 1 prepared JSON is rejected. Regenerate saved documents with
`prepareLegacyDocument` from the original CSO, asset manifest and captured assets.
Reattach historical reviews through the preparation options. Synthetic documents
constructed directly must provide context arrays and symbol operand arrays.
-Update core and React together. For pure-CSO captures, a prepared document is version 3 only when it
-contains a method or table; otherwise it remains version 2. Source CSO schema
-`1.1.0` and execution protocol 3 follow the same rule. Rebuild that document
-from the captured execution rather than rewriting old evidence.
+Update core and React together. Prepared reports containing a method or table
+use document version 6. Every reusable definition, including nested helpers,
+retains a required function identity: original module path, Python function,
+source hash and method revision. Every table carries its captured method ID and
+the same identity; core rejects missing definitions or mismatched identities.
+HTML/PDF always print the canonical `module.cso.py::function` identity, even
+when general source details are hidden. Tables link to a report-local procedure
+reference such as P1, and print continuations repeat that reference and identity.
+
+Prepared comparison reports from versions 3, 4 and 5 must be regenerated from
+retained execution and captured assets with `prepareExecutionDocument`. Do not
+invent identities from titles or filenames, mutate saved JSON to version 6,
+or rerun Python merely to migrate the presentation. Older supported execution
+protocols retain their meaning. Plain reports remain prepared version 2;
+external-only reports without a method/table remain version 4.
External captures use execution protocol 4, CSO schema `1.2.0` and prepared
-document version 4. They preserve one complete input/method/output block per
-captured call, including unused outputs and the external algorithm's formula
+document version 4 when no method or table is present. Comparisons use version 6.
+They preserve one complete input/method/output block per captured call, including unused outputs and the external algorithm's formula
verification exclusion. An optional `verificationReport` passed to
`prepareExecutionDocument` must bind to that exact capture before its outcomes
can appear. Repreparation reads captured evidence and assets without running
-the adapter. Static methods with an external boundary use the same new versions
+the adapter. Static methods with an external boundary use prepared version 6
even when no cases run. A comparison retains complete external detail blocks for
each captured case/call path. Input and output quantities use the same indexed
description, variable, value, unit and comment cells as ordinary rows. Bound
diff --git a/examples/table-reuse/README.md b/examples/table-reuse/README.md
new file mode 100644
index 0000000..1f1a07a
--- /dev/null
+++ b/examples/table-reuse/README.md
@@ -0,0 +1,34 @@
+# Table values reused downstream
+
+Three axial spring cases return extension, strain and elastic energy. Their
+comparison has three quantities across three cases, giving nine result cells.
+The parent gives all nine values new review names, uses them in three sums,
+then calculates a revised Alpha extension with an illustrative allowance.
+These sums illustrate numerical reuse across independent cases; they do not
+represent a combined mechanical system or a design assessment.
+
+The reusable definition and its table identify `axial.cso.py::response` from
+captured evidence. A report-local procedure reference links the table to the
+definition. The case headings identify the qualifiers used downstream.
+Renaming a result does not overwrite its original table value.
+
+From the repository root after following `docs/development.md`:
+
+```sh
+node packages/cso-cli/dist/cli.js bindings examples/table-reuse
+node packages/cso-cli/dist/cli.js pdf examples/table-reuse/demonstrate.cso.py \
+ --function demonstrate --out output/table-reuse.pdf --format json
+```
+
+Expected result rows are:
+
+| Quantity | Alpha, 1 kN | Beta, 2 kN | Gamma, 3 kN |
+| --- | ---: | ---: | ---: |
+| Extension, m | 0.001 | 0.002 | 0.003 |
+| Strain | 0.0005 | 0.001 | 0.0015 |
+| Elastic energy, J | 0.5 | 2 | 4.5 |
+
+The sums are 0.006 m, 0.003 and 7 J. Revised Alpha extension is 0.0011 m.
+The stiffness is 1,000,000 N/m and the gauge length is 2 m in every case.
+The 10 percent allowance is illustrative. Inspect the final PDF separately
+from numerical consistency and reference checks.
diff --git a/examples/table-reuse/axial.cso.py b/examples/table-reuse/axial.cso.py
new file mode 100644
index 0000000..78a644b
--- /dev/null
+++ b/examples/table-reuse/axial.cso.py
@@ -0,0 +1,12 @@
+"""Three public results for a linear axial spring."""
+from cso_python import CalculationResults, calculation, section
+from quantities import AppliedForce, AxialStiffness, GaugeLength, Extension, Strain, Energy
+
+@calculation(id="axial-case", title="Linear axial response")
+@section(id="axial-response", title="Axial response")
+def response(force: AppliedForce, stiffness: AxialStiffness = 1000000.0, length: GaugeLength = 2.0) -> CalculationResults:
+ extension: Extension = force / stiffness
+ strain: Strain = extension / length
+ energy: Energy = force * extension / 2
+ return {"extension": extension, "strain": strain, "energy": energy}
+
diff --git a/examples/table-reuse/demonstrate.cso.py b/examples/table-reuse/demonstrate.cso.py
new file mode 100644
index 0000000..d59d80b
--- /dev/null
+++ b/examples/table-reuse/demonstrate.cso.py
@@ -0,0 +1,29 @@
+"""A 3 by 3 comparison, nine named reuses and downstream arithmetic."""
+from typing import Annotated
+from cso_python import CalculationResults, calculation, method, section, symbol, table, text
+from _cso_bindings.axial import response
+
+@calculation(id="nine-cell-reuse", title="Nine table values reused downstream")
+@section(id="reuse-example", title="Table results remain calculation quantities", root=True)
+def demonstrate() -> CalculationResults:
+ text(id="scope", content="Three independent cases use stiffness 1,000,000 N/m and gauge length 2 m. Qualifiers ca, cb and cg identify Alpha, Beta and Gamma. Nine new names below reuse the table cells. Sums and the 10 percent allowance are illustrative.")
+ method(id="response-method", calculation=response, title="Common calculation for each case")
+ case_alpha = response(force=1000.0)
+ case_beta = response(force=2000.0)
+ case_gamma = response(force=3000.0)
+ table(id="nine-results", method="response-method", title="Three quantities across three cases", cases={"Alpha / 1 kN": case_alpha, "Beta / 2 kN": case_beta, "Gamma / 3 kN": case_gamma}, inputs=(), outputs=("extension", "strain", "energy"))
+ alpha_extension: Annotated[float, symbol(glyph=r"u_{alpha}", description="Alpha extension (new name)", unit="m")] = case_alpha["extension"]
+ alpha_strain: Annotated[float, symbol(glyph=r"epsilon_{alpha}", description="Alpha strain (new name)", unit="")] = case_alpha["strain"]
+ alpha_energy: Annotated[float, symbol(glyph=r"U_{alpha}", description="Alpha energy (new name)", unit="J")] = case_alpha["energy"]
+ beta_extension: Annotated[float, symbol(glyph=r"u_{beta}", description="Beta extension (new name)", unit="m")] = case_beta["extension"]
+ beta_strain: Annotated[float, symbol(glyph=r"epsilon_{beta}", description="Beta strain (new name)", unit="")] = case_beta["strain"]
+ beta_energy: Annotated[float, symbol(glyph=r"U_{beta}", description="Beta energy (new name)", unit="J")] = case_beta["energy"]
+ gamma_extension: Annotated[float, symbol(glyph=r"u_{gamma}", description="Gamma extension (new name)", unit="m")] = case_gamma["extension"]
+ gamma_strain: Annotated[float, symbol(glyph=r"epsilon_{gamma}", description="Gamma strain (new name)", unit="")] = case_gamma["strain"]
+ gamma_energy: Annotated[float, symbol(glyph=r"U_{gamma}", description="Gamma energy (new name)", unit="J")] = case_gamma["energy"]
+ total_extension: Annotated[float, symbol(glyph=r"u_{sum}", description="Sum of the three case extensions", unit="m")] = alpha_extension + beta_extension + gamma_extension
+ total_strain: Annotated[float, symbol(glyph=r"epsilon_{sum}", description="Sum of the three case strains", unit="")] = alpha_strain + beta_strain + gamma_strain
+ total_energy: Annotated[float, symbol(glyph=r"U_{sum}", description="Sum of the three case energies", unit="J")] = alpha_energy + beta_energy + gamma_energy
+ revised_alpha_extension: Annotated[float, symbol(glyph=r"u_{alpha,new}", description="Revised Alpha extension", unit="m")] = 1.1 * alpha_extension
+ return {"total_extension": total_extension, "total_strain": total_strain, "total_energy": total_energy, "revised_alpha_extension": revised_alpha_extension}
+
diff --git a/examples/table-reuse/quantities.py b/examples/table-reuse/quantities.py
new file mode 100644
index 0000000..5d5fc38
--- /dev/null
+++ b/examples/table-reuse/quantities.py
@@ -0,0 +1,11 @@
+"""Shared quantities for the printed table reuse example."""
+from typing import Annotated, TypeAlias
+from cso_python import symbol
+
+AppliedForce: TypeAlias = Annotated[float, symbol(glyph=r"F_{ax}", description="Applied axial force", unit="N")]
+AxialStiffness: TypeAlias = Annotated[float, symbol(glyph=r"k_{ax}", description="Axial stiffness", unit="N/m")]
+GaugeLength: TypeAlias = Annotated[float, symbol(glyph=r"L_{ax}", description="Gauge length", unit="m")]
+Extension: TypeAlias = Annotated[float, symbol(glyph=r"u_{ax}", description="Axial extension", unit="m")]
+Strain: TypeAlias = Annotated[float, symbol(glyph=r"epsilon_{ax}", description="Axial strain", unit="")]
+Energy: TypeAlias = Annotated[float, symbol(glyph=r"U_{el}", description="Elastic strain energy", unit="J")]
+
diff --git a/packages/cso-core/src/contracts/document.ts b/packages/cso-core/src/contracts/document.ts
index 676d22c..60c3a6f 100644
--- a/packages/cso-core/src/contracts/document.ts
+++ b/packages/cso-core/src/contracts/document.ts
@@ -8,7 +8,9 @@ import {
exactCaptureHash,
executionBindingKey,
HashSchema,
+ ModuleIdSchema,
NonemptyStringSchema,
+ PythonIdentifierSchema,
SourceSpanSchema,
sha256Bytes,
} from './common.ts';
@@ -280,9 +282,29 @@ export type PreparedMethodContent =
predicateText: string;
predicateSymbolId: string;
};
+export const PreparedFunctionIdentitySchema = z.strictObject({
+ moduleId: ModuleIdSchema,
+ function: PythonIdentifierSchema,
+ sourceHash: HashSchema,
+ revision: HashSchema,
+});
+export type PreparedFunctionIdentity = z.infer<
+ typeof PreparedFunctionIdentitySchema
+>;
+
+export const functionIdentityFrom = (
+ captured: PreparedFunctionIdentity,
+): PreparedFunctionIdentity => ({
+ moduleId: captured.moduleId,
+ function: captured.function,
+ sourceHash: captured.sourceHash,
+ revision: captured.revision,
+});
+
export type PreparedMethodHelper = {
id: string;
function: string;
+ functionIdentity: PreparedFunctionIdentity;
content: PreparedMethodContent[];
helpers: PreparedMethodHelper[];
contextSource?: Record;
@@ -373,6 +395,7 @@ const PreparedMethodHelperSchema: z.ZodType = z.lazy(() =>
z.strictObject({
id: NonemptyStringSchema,
function: NonemptyStringSchema,
+ functionIdentity: PreparedFunctionIdentitySchema,
content: z.array(PreparedMethodContentSchema).min(1),
helpers: z.array(PreparedMethodHelperSchema),
contextSource: HistoricalJsonObjectSchema.optional(),
@@ -463,6 +486,7 @@ export const PreparedDocumentItemSchema = z.discriminatedUnion('kind', [
kind: z.literal('method'),
id: NonemptyStringSchema,
title: NonemptyStringSchema,
+ functionIdentity: PreparedFunctionIdentitySchema,
quantities: z.array(PreparedMethodQuantitySchema).min(1),
content: z.array(PreparedMethodContentSchema).min(1),
helpers: z.array(PreparedMethodHelperSchema),
@@ -474,6 +498,8 @@ export const PreparedDocumentItemSchema = z.discriminatedUnion('kind', [
kind: z.literal('table'),
id: NonemptyStringSchema,
title: NonemptyStringSchema,
+ methodId: NonemptyStringSchema,
+ functionIdentity: PreparedFunctionIdentitySchema,
casesAxis: z.enum(['columns', 'rows']),
columnCap: z.number().int().positive().optional(),
cases: z.array(
@@ -749,7 +775,10 @@ const externalMethodInputsMatch = (
export const PreparedDocumentSchema = z
.strictObject({
- documentVersion: z.enum(['2', '3', '4', '5']),
+ documentVersion: z.enum(['2', '4', '6'], {
+ error:
+ 'Comparison reports require prepared document version 6. Reprepare from retained execution to recover captured function identities.',
+ }),
verification: VerificationReportSchema.optional(),
context: ContextSchema,
source: z.discriminatedUnion('kind', [
@@ -783,26 +812,6 @@ export const PreparedDocumentSchema = z
preparedExternalHelpers(item.helpers))),
),
);
- const booleanContent = (items: readonly PreparedMethodContent[]): boolean =>
- items.some(
- (item) =>
- item.kind === 'boolean' ||
- (item.kind === 'section' && booleanContent(item.content)),
- );
- const booleanHelpers = (
- helpers: readonly PreparedMethodHelper[],
- ): boolean =>
- helpers.some(
- (helper) =>
- booleanContent(helper.content) || booleanHelpers(helper.helpers),
- );
- const booleans = document.sections.some((section) =>
- section.items.some(
- (item) =>
- item.kind === 'method' &&
- (booleanContent(item.content) || booleanHelpers(item.helpers)),
- ),
- );
if (
external &&
(document.source.kind !== 'execution' ||
@@ -828,19 +837,16 @@ export const PreparedDocumentSchema = z
'Verification must belong to the document capture',
['verification'],
);
- if (
- (document.documentVersion === '5') !== booleans ||
- (!booleans &&
- ((document.documentVersion === '4') !== external ||
- (!external && (document.documentVersion === '3') !== comparison)))
- ) {
+ const version = comparison ? '6' : external ? '4' : '2';
+ if (document.documentVersion !== version) {
addIssue(
ctx,
'INCOMPATIBLE_CONTRACT',
- 'Comparison documents use document version 3',
+ 'Comparison reports require version 6; reprepare older reports from retained execution.',
['documentVersion'],
);
}
+ validateFunctionReferences(document, ctx);
validatePlacements(document, ctx);
validateContentIds(document, ctx);
validateAssets(document, ctx);
@@ -1098,6 +1104,89 @@ export const BoundPreparedDocumentSchema = z
['document', 'sections', sectionIndex, 'items', itemIndex],
);
}
+ if (item.kind === 'method' || item.kind === 'table') {
+ const capturedTable =
+ item.kind === 'table'
+ ? execution.tables?.find((table) => table.id === item.id)
+ : undefined;
+ const methodId =
+ item.kind === 'method' ? item.id : capturedTable?.methodId;
+ const capturedMethod = execution.methods?.find(
+ (method) => method.id === methodId,
+ );
+ if (
+ !capturedMethod ||
+ (item.kind === 'table' &&
+ item.methodId !== capturedTable?.methodId) ||
+ exactCaptureHash(item.functionIdentity) !==
+ exactCaptureHash(functionIdentityFrom(capturedMethod))
+ ) {
+ addIssue(
+ ctx,
+ 'DOCUMENT_FUNCTION_IDENTITY_MISMATCH',
+ 'Reusable definitions and tables must identify their captured function and source revision',
+ [
+ 'document',
+ 'sections',
+ sectionIndex,
+ 'items',
+ itemIndex,
+ 'functionIdentity',
+ ],
+ );
+ }
+ if (item.kind === 'method' && capturedMethod) {
+ const validateHelpers = (
+ prepared: readonly PreparedMethodHelper[],
+ captured: readonly MethodHelperRecord[],
+ ): void => {
+ if (
+ prepared.length !== captured.length ||
+ new Set(prepared.map((helper) => helper.id)).size !==
+ prepared.length
+ )
+ addIssue(
+ ctx,
+ 'DOCUMENT_FUNCTION_IDENTITY_MISMATCH',
+ 'Retain each captured reusable helper definition exactly once',
+ [
+ 'document',
+ 'sections',
+ sectionIndex,
+ 'items',
+ itemIndex,
+ 'helpers',
+ ],
+ );
+ for (const helper of prepared) {
+ const expected = captured.find(
+ (candidate) => candidate.id === helper.id,
+ );
+ if (
+ !expected ||
+ helper.function !== helper.functionIdentity.function ||
+ exactCaptureHash(helper.functionIdentity) !==
+ exactCaptureHash(functionIdentityFrom(expected))
+ )
+ addIssue(
+ ctx,
+ 'DOCUMENT_FUNCTION_IDENTITY_MISMATCH',
+ 'Reusable helper identity must match captured evidence',
+ [
+ 'document',
+ 'sections',
+ sectionIndex,
+ 'items',
+ itemIndex,
+ 'helpers',
+ ],
+ );
+ if (expected) validateHelpers(helper.helpers, expected.helpers);
+ }
+ };
+ validateHelpers(item.helpers, capturedMethod.helpers);
+ }
+ }
if (item.kind === 'method') {
const method = execution.methods?.find(
(method) => method.id === item.id,
@@ -1261,6 +1350,52 @@ const validatePlacements = (
}
};
+const validateFunctionReferences = (
+ document: PreparedDocument,
+ ctx: z.RefinementCtx,
+): void => {
+ const validateHelpers = (helpers: readonly PreparedMethodHelper[]): void => {
+ for (const helper of helpers) {
+ if (helper.function !== helper.functionIdentity.function)
+ addIssue(
+ ctx,
+ 'DOCUMENT_FUNCTION_IDENTITY_MISMATCH',
+ 'Reusable helper names must agree with their canonical function identity',
+ ['sections'],
+ );
+ validateHelpers(helper.helpers);
+ }
+ };
+ const methods = document.sections.flatMap((section) =>
+ section.items.flatMap((item) => (item.kind === 'method' ? [item] : [])),
+ );
+ for (const method of methods) validateHelpers(method.helpers);
+ if (new Set(methods.map((method) => method.id)).size !== methods.length)
+ addIssue(
+ ctx,
+ 'DOCUMENT_FUNCTION_IDENTITY_MISMATCH',
+ 'Reusable definition identities must be unique',
+ ['sections'],
+ );
+ for (const [sectionIndex, section] of document.sections.entries()) {
+ for (const [itemIndex, item] of section.items.entries()) {
+ if (item.kind !== 'table') continue;
+ const definition = methods.find((method) => method.id === item.methodId);
+ if (
+ !definition ||
+ exactCaptureHash(definition.functionIdentity) !==
+ exactCaptureHash(item.functionIdentity)
+ )
+ addIssue(
+ ctx,
+ 'DOCUMENT_FUNCTION_IDENTITY_MISMATCH',
+ 'Every table must reference a displayed reusable definition with the same function identity',
+ ['sections', sectionIndex, 'items', itemIndex, 'methodId'],
+ );
+ }
+ }
+};
+
const validatePlacementIdentity = (
placement: PreparedDocumentSection | PreparedDocumentItem,
ctx: z.RefinementCtx,
diff --git a/packages/cso-core/src/index.ts b/packages/cso-core/src/index.ts
index 3a127f9..e868861 100644
--- a/packages/cso-core/src/index.ts
+++ b/packages/cso-core/src/index.ts
@@ -249,6 +249,9 @@ export {
type PreparedContextField,
PreparedDocumentItemSchema,
PreparedDocumentSchema,
+ PreparedFunctionIdentitySchema,
+ functionIdentityFrom,
+ type PreparedFunctionIdentity,
PreparedDocumentSectionSchema,
ResolvedAssetSchema,
type HistoricalReview,
diff --git a/packages/cso-python/src/cso_python/authoring.md b/packages/cso-python/src/cso_python/authoring.md
index 0082d29..c20f9ee 100644
--- a/packages/cso-python/src/cso_python/authoring.md
+++ b/packages/cso-python/src/cso_python/authoring.md
@@ -89,7 +89,9 @@ intermediate work.
Declare `method(id=..., calculation=..., title=...)` before the calls it
explains, then `table(...)` after those calls. The method and every case share
-one symbolic plan from that source revision. A table does not rerun the
+one symbolic plan from that source revision. The report always identifies the
+original source module and Python function on the reusable definition and every
+table using it. Import aliases and display titles do not replace that identity. A table does not rerun the
calculation or invent a second reading of the formulas.
Core resolves the method's formulas through each case's parameter and helper-call
@@ -133,7 +135,8 @@ stop the run.
Captures that contain method or table evidence use protocol version 3 and CSO
schema `1.1.0`. Prepared documents that contain those items use document
-version 3. Protocol 2 and prepared document version 2 remain valid when the
+version 6, with mandatory captured function identification on the definition
+and every table. Protocol 2 and prepared document version 2 remain valid when the
source has no method or table. Rebuild a prepared document from the retained
execution with `prepareExecutionDocument`. Do not rebind an older execution to
a new numerical run in order to add a method.
@@ -183,7 +186,7 @@ printed continuations. No cases means "No cases evaluated", not a successful
verdict.
Boolean declarations use execution protocol 5, CSO schema `1.3.0` and prepared
-document version 5. Update Python, core, React and CLI together before using this
+document version 6. Update Python, core, React and CLI together before using this
feature. Older captures retain their existing versions and meaning. A consumer
that supports only protocol 4 rejects Boolean captures before numerical execution.
Math-only CSO export rejects Boolean report evidence rather than dropping it.
diff --git a/packages/cso-react/src/PreparedFormulaSheet.tsx b/packages/cso-react/src/PreparedFormulaSheet.tsx
index f8f6ae1..52173f9 100644
--- a/packages/cso-react/src/PreparedFormulaSheet.tsx
+++ b/packages/cso-react/src/PreparedFormulaSheet.tsx
@@ -8,6 +8,7 @@ import {
type PreparedDocumentSection,
type PreparedMethodContent,
type PreparedMethodHelper,
+ type PreparedFunctionIdentity,
type PreparedOutcomeRow,
type SheetDocument,
} from '@cs-object/core';
@@ -158,12 +159,31 @@ const SymbolicValueNote = (): ReactElement => (
);
+const procedureAnchor = (id: string): string => `cso-procedure-${id}`;
+const FunctionIdentity = ({
+ identity,
+}: {
+ identity: PreparedFunctionIdentity;
+}): ReactElement => (
+
+ Function:{' '}
+
+ {identity.moduleId}::{identity.function}
+
+
+);
+
const Procedure = ({
title,
children,
heading: Heading,
+ functionIdentity,
}: {
title: string;
+ functionIdentity: PreparedFunctionIdentity;
children: readonly ReactNode[];
heading: 'h3' | 'h4';
}): ReactElement => (
@@ -172,6 +192,7 @@ const Procedure = ({
|
{title}
+
|
@@ -191,6 +212,13 @@ export const PreparedFormulaSheet = ({
showSourceDetails = true,
}: PreparedFormulaSheetProps): ReactElement => {
const document = PreparedDocumentSchema.parse(input);
+ const procedures = new Map(
+ document.sections
+ .flatMap((section) =>
+ section.items.flatMap((item) => (item.kind === 'method' ? [item] : [])),
+ )
+ .map((method, index) => [method.id, `P${index + 1}`]),
+ );
const sheet = mathSheet(document);
const sections = new Map(
document.sections.map((section) => [section.id, section]),
@@ -442,7 +470,11 @@ export const PreparedFormulaSheet = ({
shown.add(helper.id);
explanation = (
-
+
{[
-
+
{[
{item.title}
+
+
+ Uses procedure {requiredItem(procedures, item.methodId)}
+
+ {': '}
+
+ {item.functionIdentity.moduleId}::
+ {item.functionIdentity.function}
+
+
{!item.outcomeRows &&
item.engineeringChecks?.map((check) =>
check.results.map((result) => (
diff --git a/packages/cso-react/src/formula-sheet/partition-comparisons.ts b/packages/cso-react/src/formula-sheet/partition-comparisons.ts
index 1849941..cf411a1 100644
--- a/packages/cso-react/src/formula-sheet/partition-comparisons.ts
+++ b/packages/cso-react/src/formula-sheet/partition-comparisons.ts
@@ -330,6 +330,10 @@ export function renderComparisonParts(
if (plan.groups.length > 1)
title.textContent += `. Part ${partIndex + 1} of ${plan.groups.length}`;
context.appendChild(title);
+ const methodReference = comparison.querySelector(
+ ':scope > [data-method-reference]',
+ );
+ if (methodReference) context.appendChild(methodReference.cloneNode(true));
for (const limit of table.querySelectorAll(
'th[data-outcome-kind="applicability"]',
)) {
diff --git a/packages/cso-react/src/prepare-document.ts b/packages/cso-react/src/prepare-document.ts
index 5957c0a..5ba60dd 100644
--- a/packages/cso-react/src/prepare-document.ts
+++ b/packages/cso-react/src/prepare-document.ts
@@ -24,6 +24,7 @@ import {
type PreparedDocumentItem,
PreparedDocumentItemSchema,
PreparedDocumentSchema,
+ functionIdentityFrom,
PreparedDocumentSectionSchema,
type PreparedMethodContent,
type PreparedMethodHelper,
@@ -723,6 +724,7 @@ const presentMethodHelpers = (
helpers.map((helper) => ({
id: helper.id,
function: helper.function,
+ functionIdentity: functionIdentityFrom(helper),
contextSource: HistoricalReviewSchema.shape.originalFields.parse({
metadata: helper.metadata ?? {},
}),
@@ -851,6 +853,7 @@ const comparisonPlacement = (
kind: 'method',
id: item.id,
title: method.title,
+ functionIdentity: functionIdentityFrom(method),
contextSource: HistoricalReviewSchema.shape.originalFields.parse({
metadata: item.metadata ?? {},
contentMetadata: method.metadata ?? {},
@@ -1031,6 +1034,8 @@ const comparisonPlacement = (
kind: 'table',
id: item.id,
title: table.title,
+ methodId: method.id,
+ functionIdentity: functionIdentityFrom(method),
casesAxis: table.casesAxis,
...(table.columnCap ? { columnCap: table.columnCap } : {}),
...(branchNotes.length > 0 ? { branchNotes } : {}),
@@ -1274,18 +1279,15 @@ export const prepareExecutionDocument = (
);
const { metadata: _sourceMetadata, ...sourceProvenance } = cso.source;
const document = parseDocument({
- documentVersion:
- execution.cso.schemaVersion === '1.3.0'
- ? '5'
- : execution.externalCalls
- ? '4'
- : sections.some((section) =>
- section.items.some(
- (item) => item.kind === 'method' || item.kind === 'table',
- ),
- )
- ? '3'
- : '2',
+ documentVersion: sections.some((section) =>
+ section.items.some(
+ (item) => item.kind === 'method' || item.kind === 'table',
+ ),
+ )
+ ? '6'
+ : execution.externalCalls
+ ? '4'
+ : '2',
context: [],
...(verification ? { verification } : {}),
source: {
diff --git a/packages/cso-react/src/styles.css b/packages/cso-react/src/styles.css
index fe453ad..1c866d7 100644
--- a/packages/cso-react/src/styles.css
+++ b/packages/cso-react/src/styles.css
@@ -161,6 +161,9 @@ math {
.cso-method-external { margin: 2mm 0; }
.cso-method-external h4, .cso-method-external h5 { font-size: 12px; font-weight: 650; margin: 2mm 0 1mm; }
.cso-method-external p, .cso-external-reference-coverage p { margin: 1mm 0; }
+.cso-function-identity { margin: 1mm 0; font-size: 0.85em; font-weight: 400; overflow-wrap: anywhere; }
+.cso-function-identity code { white-space: normal; }
+.cso-function-identity a { color: inherit; }
.cso-comparison table { width: max-content; min-width: 100%; border-collapse: collapse; table-layout: auto; font-size: 11px; }
.cso-comparison th, .cso-comparison td { border: 1px solid #d0d5dd; padding: 1.75mm 2.5mm; overflow-wrap: anywhere; white-space: pre-wrap; vertical-align: middle; }
.cso-comparison th { text-align: left; font-weight: 600; }
@@ -192,7 +195,7 @@ math {
}
@media print {
.cso-comparison-scroll, .cso-comparison > .cso-branch-note { display: none; }
- .cso-comparison:has(.cso-comparison-parts) > h3 { display: none; }
+ .cso-comparison:has(.cso-comparison-parts) > h3, .cso-comparison:has(.cso-comparison-parts) > [data-method-reference] { display: none; }
.cso-comparison-part, .cso-comparison-slice { break-inside: auto; }
.cso-comparison-part + .cso-comparison-part { margin-top: 4mm; }
.cso-comparison-part tr { break-inside: avoid; }
diff --git a/tests/integration/boolean-tables.test.ts b/tests/integration/boolean-tables.test.ts
index bb449e5..4302639 100644
--- a/tests/integration/boolean-tables.test.ts
+++ b/tests/integration/boolean-tables.test.ts
@@ -61,7 +61,7 @@ for (const axis of ['columns', 'rows']) {
test(`${axis}: typed values, nested engineering verdicts and selected rules align with retained cases`, () => {
const { response, document, table } = prepare(axis);
expect(response.protocolVersion).toBe('5');
- expect(document.documentVersion).toBe('5');
+ expect(document.documentVersion).toBe('6');
expect(verifyExecution({ execution: response.execution }).ok).toBe(true);
expect(table.casesAxis).toBe(axis);
expect(table.groups).toEqual([]);
diff --git a/tests/integration/method-table.test.ts b/tests/integration/method-table.test.ts
index 275105b..3dec811 100644
--- a/tests/integration/method-table.test.ts
+++ b/tests/integration/method-table.test.ts
@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
BoundPreparedDocumentSchema,
+ PreparedDocumentSchema,
deriveApplicability,
ExecutionResponseSchema,
verifyExecution,
@@ -305,6 +306,16 @@ beforeAll(() => {
{ encoding: 'utf8' },
);
expect(installed.status, installed.stderr).toBe(0);
+ writeFileSync(
+ join(directory, 'aliased_compare.cso.py'),
+ publication
+ .replace(
+ 'import rectangle\n',
+ 'import rectangle as renamed_calculation\n',
+ )
+ .replace('calculation=rectangle', 'calculation=renamed_calculation')
+ .replaceAll('= rectangle(', '= renamed_calculation('),
+ );
writeFileSync(
join(directory, 'forwarded.cso.py'),
`from typing import Annotated, TypeAlias
@@ -611,6 +622,104 @@ function publish(
);
}
+test('requires captured function identification on definitions and tables', () => {
+ const execution = capture('aliased_compare.cso.py', 'compare');
+ const document = prepareExecutionDocument({ execution, assets: [] });
+ const items = document.sections.flatMap((section) => section.items);
+ const definition = items.find((item) => item.kind === 'method');
+ const table = items.find((item) => item.kind === 'table');
+ const method = execution.methods?.[0];
+ if (definition?.kind !== 'method' || table?.kind !== 'table' || !method)
+ throw new Error('Missing comparison');
+ const expected = {
+ moduleId: 'rectangle.cso.py',
+ function: 'rectangle',
+ sourceHash: method.sourceHash,
+ revision: method.revision,
+ };
+ expect(document.documentVersion).toBe('6');
+ expect(definition.functionIdentity).toEqual(expected);
+ expect(table.functionIdentity).toEqual(expected);
+ expect(table.methodId).toBe(definition.id);
+ expect(
+ BoundPreparedDocumentSchema.safeParse({ execution, document }).success,
+ ).toBe(true);
+ const html = renderToStaticMarkup(
+ createElement(PreparedFormulaSheet, { document, showSourceDetails: false }),
+ );
+ expect(elementWith(html, 'data-method-id=')).toContain('Function:');
+ expect(elementWith(html, 'data-method-id=')).toContain(
+ 'rectangle.cso.py::rectangle',
+ );
+ expect(elementWith(html, 'data-comparison-id=')).toContain(
+ 'rectangle.cso.py::rectangle',
+ );
+ expect(html).toContain('id="cso-procedure-P1"');
+ expect(html).toContain('href="#cso-procedure-P1"');
+ expect(html).toContain('Uses procedure P1');
+ expect(html).not.toContain('::renamed_calculation');
+
+ for (const target of ['method', 'table']) {
+ const candidate = {
+ ...document,
+ sections: document.sections.map((section) => ({
+ ...section,
+ items: section.items.map((item) => {
+ if (
+ item.kind !== target ||
+ (item.kind !== 'method' && item.kind !== 'table')
+ )
+ return item;
+ const { functionIdentity: _, ...withoutIdentity } = item;
+ return withoutIdentity;
+ }),
+ })),
+ };
+ expect(PreparedDocumentSchema.safeParse(candidate).success).toBe(false);
+ }
+ for (const field of ['moduleId', 'function', 'sourceHash', 'revision']) {
+ const tampered = structuredClone(document);
+ for (const item of tampered.sections.flatMap((section) => section.items)) {
+ if (item.kind !== 'method' && item.kind !== 'table') continue;
+ if (field === 'moduleId')
+ item.functionIdentity.moduleId = 'unrelated.cso.py';
+ if (field === 'function') item.functionIdentity.function = 'unrelated';
+ if (field === 'sourceHash')
+ item.functionIdentity.sourceHash = '0'.repeat(64);
+ if (field === 'revision') item.functionIdentity.revision = '0'.repeat(64);
+ }
+ // Altering both copies consistently must still fail against captured evidence.
+ expect(PreparedDocumentSchema.safeParse(tampered).success).toBe(true);
+ expect(
+ BoundPreparedDocumentSchema.safeParse({ execution, document: tampered })
+ .success,
+ ).toBe(false);
+ }
+ const withoutDefinition = {
+ ...document,
+ sections: document.sections.map((section) => ({
+ ...section,
+ items: section.items.filter((item) => item.kind !== 'method'),
+ })),
+ };
+ expect(PreparedDocumentSchema.safeParse(withoutDefinition).success).toBe(
+ false,
+ );
+ const wrongBinding = structuredClone(document);
+ const wrongTable = wrongBinding.sections
+ .flatMap((section) => section.items)
+ .find((item) => item.kind === 'table');
+ if (wrongTable?.kind !== 'table') throw new Error('Missing table');
+ wrongTable.methodId = 'unrelated-method';
+ expect(PreparedDocumentSchema.safeParse(wrongBinding).success).toBe(false);
+ for (const documentVersion of ['3', '4', '5']) {
+ expect(
+ PreparedDocumentSchema.safeParse({ ...document, documentVersion })
+ .success,
+ ).toBe(false);
+ }
+});
+
test('publishes one straight-line method and bound case columns', async () => {
const execution = capture('compare.cso.py', 'compare');
expect(execution.cso.schemaVersion).toBe('1.1.0');
@@ -618,7 +727,7 @@ test('publishes one straight-line method and bound case columns', async () => {
const first = prepareExecutionDocument({ execution, assets: [] });
const second = prepareExecutionDocument({ execution, assets: [] });
expect(second).toEqual(first);
- expect(first.documentVersion).toBe('3');
+ expect(first.documentVersion).toBe('6');
const root = first.sections[0];
expect(root?.items.map((item) => item.kind)).toEqual([
'method',
@@ -1387,6 +1496,10 @@ test('continues a wide comparison across measured column groups', () => {
expect(text.stdout).toContain('Part 1 of 3');
expect(text.stdout).toContain('Part 3 of 3');
expect(text.stdout.match(/Subscript:/g)?.length).toBeGreaterThanOrEqual(3);
+ expect(
+ text.stdout.replace(/\s+/g, '').match(/wide_checked.cso.py::rectangle/g)
+ ?.length,
+ ).toBeGreaterThanOrEqual(4);
expect(text.stdout).toContain('Not applicable');
expect(text.stdout).toContain('Perimeter exceeds twelve metres.');
expect(text.stdout).toContain('m2');
@@ -1403,6 +1516,10 @@ test('continues a wide comparison across measured column groups', () => {
expect(rowsText.stdout).toContain('Not applicable');
expect(rowsText.stdout.match(/Case A/g)?.length).toBeGreaterThan(1);
expect(rowsText.stdout.match(/Subscript:/g)?.length).toBeGreaterThan(1);
+ expect(
+ rowsText.stdout.replace(/\s+/g, '').match(/wide_checked.cso.py::rectangle/g)
+ ?.length,
+ ).toBeGreaterThan(1);
const sentinel = join(directory, 'unfit-sentinel.pdf');
writeFileSync(sentinel, 'keep');
@@ -1447,7 +1564,10 @@ test('repeats comparison context across vertical page breaks', () => {
expect(page).toMatch(/Inputs|Outputs|Checks and conditions/);
}
}
- expect(rowPages.at(-1)).toContain('Case 64');
+ const casePages = rowPages.filter((page) => page.includes('Case '));
+ expect(casePages.at(-1)).toContain('Case 64');
+ for (const page of casePages)
+ expect(page).toContain('tall_rectangle.cso.py::rectangle');
expect(rowPages.join('\n')).toContain('Not applicable');
expect(rowPages.join('\n')).toContain('Perimeter exceeds twelve metres.');
const rowHash = createHash('sha256')
@@ -1524,6 +1644,24 @@ test('composed method returns keep exact case bindings in both orientations', ()
);
expect(html).toContain('cso-symbol-row');
expect(html).toContain('data-quantity="area"');
+ expect(html).toContain('forwarded.cso.py::forwarded');
+ expect(html).toContain('rectangle.cso.py::rectangle');
+ const renamedHelper = structuredClone(document);
+ const helperMethod = renamedHelper.sections
+ .flatMap((section) => section.items)
+ .find((item) => item.kind === 'method');
+ const helper =
+ helperMethod?.kind === 'method' ? helperMethod.helpers[0] : undefined;
+ if (!helper) throw new Error('Missing reusable helper');
+ helper.functionIdentity.function = 'different_function';
+ expect(PreparedDocumentSchema.safeParse(renamedHelper).success).toBe(false);
+ helper.function = 'different_function';
+ expect(
+ BoundPreparedDocumentSchema.safeParse({
+ execution,
+ document: renamedHelper,
+ }).success,
+ ).toBe(false);
const tampered = structuredClone(execution);
const cell = tampered.tables?.[0]?.cells.find(
(cell) => cell.symbolId === first.symbolId,
@@ -1573,7 +1711,7 @@ test('mixed external methods retain complete case-specific indexed blocks in bot
assets: [],
verificationReport: report,
});
- expect(document.documentVersion).toBe('4');
+ expect(document.documentVersion).toBe('6');
const blocks = document.sections.flatMap((section) =>
section.items.filter((item) => item.kind === 'externalCall'),
);
@@ -1697,7 +1835,7 @@ test('empty external tables describe unimportable adapters without running repre
expect(html).toContain('data-method-external="searched"');
expect(html).toContain('Unused diagnostic count');
expect(html).toContain('No cases evaluated');
- expect(document.documentVersion).toBe('4');
+ expect(document.documentVersion).toBe('6');
const methodsPath = join(directory, 'external_methods.py');
writeFileSync(
methodsPath,
diff --git a/tests/integration/section-composition.test.ts b/tests/integration/section-composition.test.ts
index 1115681..e895b18 100644
--- a/tests/integration/section-composition.test.ts
+++ b/tests/integration/section-composition.test.ts
@@ -114,7 +114,7 @@ it.each([
parameters?.every((parameter) => parameter.origin.kind === 'output'),
).toBe(true);
const document = prepareExecutionDocument({ execution, assets: [] });
- expect(document.documentVersion).toBe('3');
+ expect(document.documentVersion).toBe('6');
const items = document.sections.flatMap((section) => section.items);
const methodItem = items.find((item) => item.kind === 'method');
const tableItem = items.find((item) => item.kind === 'table');