Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions docs/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions examples/table-reuse/README.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions examples/table-reuse/axial.cso.py
Original file line number Diff line number Diff line change
@@ -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}

29 changes: 29 additions & 0 deletions examples/table-reuse/demonstrate.cso.py
Original file line number Diff line number Diff line change
@@ -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}

11 changes: 11 additions & 0 deletions examples/table-reuse/quantities.py
Original file line number Diff line number Diff line change
@@ -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")]

191 changes: 163 additions & 28 deletions packages/cso-core/src/contracts/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
exactCaptureHash,
executionBindingKey,
HashSchema,
ModuleIdSchema,
NonemptyStringSchema,
PythonIdentifierSchema,
SourceSpanSchema,
sha256Bytes,
} from './common.ts';
Expand Down Expand Up @@ -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<string, HistoricalJsonValue>;
Expand Down Expand Up @@ -373,6 +395,7 @@ const PreparedMethodHelperSchema: z.ZodType<PreparedMethodHelper> = z.lazy(() =>
z.strictObject({
id: NonemptyStringSchema,
function: NonemptyStringSchema,
functionIdentity: PreparedFunctionIdentitySchema,
content: z.array(PreparedMethodContentSchema).min(1),
helpers: z.array(PreparedMethodHelperSchema),
contextSource: HistoricalJsonObjectSchema.optional(),
Expand Down Expand Up @@ -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),
Expand All @@ -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(
Expand Down Expand Up @@ -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', [
Expand Down Expand Up @@ -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' ||
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading