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');