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
18 changes: 15 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to this reference implementation are documented here.

## Unreleased

### Added

- Implement explicitly selected official SPSS Frontend 0.3 over unchanged
Plan 0.1/0.2 through the shared parser/binder, with a strict
`compile_spss_request` boundary and opt-in live apply provenance. Support
comments, dictionary-order `TO`, grouped commands, finite open RECODE ranges,
additive ordered typed labels, inequality aliases, and canonical NOT lowering.
Precedence is comparisons → NOT → AND → OR; parentheses override it.
Tests cover all 90 effective normative cases plus request boundaries and
in-place SQLite identity/metadata/audit/no-artifact checks. Default APIs and
Python extension support/rejections remain unchanged.

### Fixed

- Namespace new Python schema-change plans and SPSS output as
Expand All @@ -12,9 +24,9 @@ All notable changes to this reference implementation are documented here.
The former `openstatspec-transformation-plan-v0.3` remains accepted as a
legacy Python extension with unchanged semantics, canonical JSON, and hashes;
stored audits are not migrated. New compilation changes schema-plan hashes.
- Clarify that specification `v0.5.0` has no official Plan 0.3 and that its
syntax-only Frontend 0.3 over Plan 0.1/0.2 is not implemented here. Legacy
acceptance and Python extension tests do not claim official conformance.
- Clarify that specification `v0.5.0` has no official Plan 0.3. Its syntax-only
Frontend 0.3 over Plan 0.1/0.2 is separate from Python schema extensions.
Legacy acceptance and Python extension tests do not claim official conformance.
See [compatibility and migration](docs/transformations.md#contract-ownership-and-legacy-compatibility).

## 0.8.1 - 2026-09-10
Expand Down
76 changes: 74 additions & 2 deletions docs/transformations.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ they are never treated as arbitrary SQL.

Published OpenStatSpec `v0.5.0` defines Transformation Plan 0.1/0.2, not
Plan 0.3. Its optional SPSS Frontend 0.3 is a syntax-only expansion emitting
Plan 0.1/0.2; this adapter does **not** implement that frontend yet.
Plan 0.1/0.2, implemented through the explicit official request boundary below.

Explicit `create_variable` / `delete_variable` operations (including SPSS
`STRING` / `DELETE VARIABLES`) belong to a **Python extension**, not official
Expand All @@ -46,7 +46,7 @@ OpenStatSpec conformance. New schema-changing compilations emit:
The exported names `TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT` and
`SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT` remain unchanged; their values now use
these Python-owned identifiers. Plans without these explicit schema operations
retain their existing Plan 0.1/0.2 selection and frontend identifier. Official
retain their existing Plan 0.1/0.2 selection and default frontend identifier. Official
Plan 0.1/0.2 still reject explicit create/delete operations.

The loader and executor continue to accept the old Python plan identifier
Expand All @@ -64,6 +64,78 @@ when its operations are identical. An intentional change of a saved plan's
contract likewise creates a new artifact with a new hash, not an audit migration.
Older adapter versions cannot load the new IDs.

## Official Frontend 0.3

Use `openstatspec.compile_spss_request(request)` for the official JSON request
boundary. It accepts **only** `openstatspec-spss-syntax-frontend-v0.3`, with
exact required fields `contract`, `input_alias`, `input_schema`, and
`source_text`. Unknown fields, missing fields, wrong types, noncanonical or
nonfinite typed codes, ambiguous variable names, and mismatched label types
fail closed. Request-shape failures report `invalid_spss_request`; source
failures retain the normative diagnostics. It returns no partial compilation.

```python
compilation = openstatspec.compile_spss_request({
"contract": "openstatspec-spss-syntax-frontend-v0.3",
"input_alias": "parent",
"input_schema": {"variables": [
{"name": "age", "storage_kind": "numeric"},
]},
"source_text": "COMMENT finite ages. RECODE age (LOWEST THRU 17 = 0).",
})
```

The ordered request dictionary accepts only `name`, `storage_kind`,
`variable_label`, ordered typed `value_labels`, `format_family`, `width`,
`decimals`, and `measurement_level`. In particular, `width`/`decimals` are
request field names, not `format_width`/`format_decimals`. Descriptive input
format metadata is preserved, including partial metadata and string formats;
`FORMATS` operations still accept only bounded numeric F formats. Physical
identifiers and Python `declared_string_width` are not request fields.

The same parser/binder implements command-boundary star and `COMMENT`
comments, non-nested block comments, dictionary-order inclusive `TO` ranges,
grouped recodes/labels/formats/levels, finite `LOWEST`/`HIGHEST` RECODE bounds,
`NE`/`<>`/`~=`, `NOT`, and `ADD VALUE LABELS`. `TO` cannot generate `INTO`
targets. Additive labels update an existing typed code at its ordinal and
append new codes in source order, using the preceding label state. Numeric
zero has positive-zero identity; string codes retain exact contents.
Comments remain in the LF-normalized source hash but emit no operation;
comment-only input is invalid. Python `STRING` and `DELETE VARIABLES` are
rejected under the official selector.

**Explicit implementation decision:** precedence is standard SPSS order,
comparisons → `NOT` → `AND` → `OR` (tightest first). Thus `NOT a = 1 AND
b = 1` means `(NOT (a = 1)) AND b = 1`, not negation of the conjunction.
Parentheses override precedence. Comparison complements and De Morgan lowering
preserve SQL UNKNOWN, and maximal same-operator nodes flatten in source order.
This decision has adapter regression tests; normative fixtures are unchanged.

Programs using only Plan 0.1 operations retain their exact Plan 0.1 object;
any Plan 0.2-only operation selects Plan 0.2. Both source and complete canonical
plan hashes remain independent. Official compilations record the 0.3 frontend
identifier regardless of output-plan version.

For typed-schema or live-database callers, explicitly pass
`frontend_contract="openstatspec-spss-syntax-frontend-v0.3"` to
`compile_spss_syntax` or `apply_spss_in_place`. The latter compiles against the
live schema within the existing transaction and records official frontend and
source/plan provenance. JSON callers should use `compile_spss_request` rather
than building a typed schema from untrusted fields. Omitting the selector
retains all old support, rejection behavior, and Python extension selection;
the existing CLI remains on that compatibility path.

Evidence: `tests/test_frontend_v03.py` runs the 35 declared and all 90 effective
cases from specification commit
`864e84479f554b8ee250ffed44c4dfb963750d4a`, applying only the published inherited
contract overrides and two comment supersessions. It checks exact Plan 0.1/0.2
objects/hashes, source hashes, diagnostics, and declared output metadata.
Local SQLite integration checks UNKNOWN, sequential data and metadata,
identity, audit provenance, and absence of copy/history artifacts. Existing
frontend, plan, and in-place suites remain in the gate. This is not new service
execution evidence: MySQL/MariaDB/Dolt provisioning restrictions and caller-owned
Dolt commits are unchanged.

## Install the audit schema

Install the compact audit relation once before the first apply:
Expand Down
4 changes: 2 additions & 2 deletions src/openstatspec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
remove_derived_physical_relation, retire_derived, validate, validate_derived,
)
from .core import CapabilityDeclaration, LossReport, UnsupportedOperationError
from .frontends.spss import SpssFrontendCompilation, compile_spss_syntax
from .frontends.spss import SpssFrontendCompilation, compile_spss_syntax, compile_spss_request
from .sql import DoltConformanceSource
from .sql.workflow import TransformationError
from .transform import (
Expand All @@ -37,7 +37,7 @@
"TransformationPlan", "TypedValue", "ValueLabel",
"VariableDefinition", "VariableSchema", "transformation_plan_from_dict",
"apply_spss_in_place", "apply_transformation_plan_in_place",
"compile_spss_syntax", "install_in_place_transformation_schema",
"compile_spss_syntax", "compile_spss_request", "install_in_place_transformation_schema",
"UnsupportedOperationError", "capabilities", "capability_matrix",
"derive_sql_dataset", "dolt_state_snapshot", "execute_sql_transformation", "export_sav", "get_dataset",
"import_sav", "initialize_catalog", "inspect", "list_datasets",
Expand Down
4 changes: 3 additions & 1 deletion src/openstatspec/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ def derive_sql_dataset(*, database_url: Any, **options: Any) -> Mapping[str, Any

def apply_spss_in_place(
*, database_url: Any, dataset_id: str, source_text: str,
actor: str, expected_branch: str | None = None,
actor: str, frontend_contract: str | None = None,
expected_branch: str | None = None,
expected_head: str | None = None,
dolt_conformance_source: DoltConformanceSource | None = None,
) -> Mapping[str, Any]:
Expand All @@ -239,6 +240,7 @@ def apply_spss_in_place(
dataset_id=dataset_id,
source_text=source_text,
actor=actor,
frontend_contract=frontend_contract,
expected_branch=expected_branch,
expected_head=expected_head,
dolt_conformance_source=dolt_conformance_source,
Expand Down
4 changes: 4 additions & 0 deletions src/openstatspec/frontends/spss/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from .binding import bind_spss_syntax
from .compiler import (
SPSS_FRONTEND_CONTRACT,
SPSS_FRONTEND_V03_CONTRACT,
SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT,
SpssFrontendCompilation,
compile_spss_syntax,
compile_spss_request,
)
from .syntax import (
SpssSyntaxProgram,
Expand All @@ -18,11 +20,13 @@

__all__ = [
"SPSS_FRONTEND_CONTRACT",
"SPSS_FRONTEND_V03_CONTRACT",
"SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT",
"SpssFrontendCompilation",
"SpssSyntaxProgram",
"bind_spss_syntax",
"compile_spss_syntax",
"compile_spss_request",
"normalize_spss_source",
"parse_spss_syntax",
"spss_source_hash",
Expand Down
75 changes: 64 additions & 11 deletions src/openstatspec/frontends/spss/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from .syntax import (
BooleanSyntax, ComparisonSyntax, ComputeCommandSyntax, ExecuteCommandSyntax,
DeleteVariablesCommandSyntax, FormatsCommandSyntax, IfCommandSyntax,
OperandSyntax, PredicateSyntax,
NotSyntax, OperandSyntax, PredicateSyntax, Token, VariableRangeSyntax,
RecodeCommandSyntax, RecodeMatchSyntax, RecodeResultSyntax,
SpssSyntaxProgram, SyntaxLiteral, ValueLabelsCommandSyntax,
StringCommandSyntax, VariableLabelsCommandSyntax, VariableLevelCommandSyntax,
Expand Down Expand Up @@ -74,9 +74,16 @@ def _bind_operand(


def _bind_predicate(
syntax: PredicateSyntax, variables: list[VariableDefinition],
syntax: PredicateSyntax, variables: list[VariableDefinition], *, negated: bool = False,
) -> PredicateExpression:
if isinstance(syntax, NotSyntax):
return _bind_predicate(syntax.operand, variables, negated=not negated)
if isinstance(syntax, ComparisonSyntax):
if syntax.operator == "ne" or (negated and syntax.operator == "="):
expanded = BooleanSyntax("or", tuple(
replace(syntax, operator=operator) for operator in ("<", ">")
), syntax.span)
return _bind_predicate(expanded, variables, negated=negated and syntax.operator == "ne")
left, left_type = _bind_operand(syntax.left, variables)
right, right_type = _bind_operand(syntax.right, variables)
if left_type != right_type:
Expand All @@ -91,19 +98,21 @@ def _bind_predicate(
"Ordered comparisons require numeric operands.",
span=syntax.span, operator=syntax.operator,
)
return ComparisonExpression(left, syntax.operator, right)
operator = ({"=": "=", "<": ">=", "<=": ">", ">": "<=", ">=": "<"}[syntax.operator] if negated else syntax.operator)
return ComparisonExpression(left, operator, right)
assert isinstance(syntax, BooleanSyntax)
operator = ("or" if syntax.operator == "and" else "and") if negated else syntax.operator
operands: list[PredicateExpression] = []
for operand in syntax.operands:
bound = _bind_predicate(operand, variables)
bound = _bind_predicate(operand, variables, negated=negated)
if (
isinstance(bound, BooleanExpression)
and bound.operator == syntax.operator
and bound.operator == operator
):
operands.extend(bound.operands)
else:
operands.append(bound)
return BooleanExpression(syntax.operator, tuple(operands))
return BooleanExpression(operator, tuple(operands))


def _assignment(
Expand Down Expand Up @@ -151,7 +160,7 @@ def _assignment(


def _match(
syntax: RecodeMatchSyntax, source: VariableDefinition,
syntax: RecodeMatchSyntax, source: VariableDefinition, *, official_v03: bool = False,
) -> RecodeMatch:
expected = _expected_type(source.storage_kind)
if syntax.kind == "system_missing":
Expand All @@ -172,7 +181,7 @@ def _match(
)
if lower.number() > upper.number():
raise frontend_error(
"invalid_numeric_range",
"invalid_variable_range" if official_v03 else "invalid_numeric_range",
"THRU lower endpoint exceeds its upper endpoint.",
span=syntax.span, variable=source.name,
)
Expand Down Expand Up @@ -252,10 +261,12 @@ def _validate_recode_string_width(


def _bind_recode(
command: RecodeCommandSyntax, variables: list[VariableDefinition],
command: RecodeCommandSyntax, variables: list[VariableDefinition], *, official_v03: bool = False,
) -> tuple[list[RecodeOperation], list[SourceSpan]]:
sources = [_resolve(variables, token.text, token.span)[1] for token in command.sources]
targets = command.targets
if targets is not None and len(targets) != len(sources):
raise frontend_error("spss_syntax_error", "RECODE INTO requires one target per source.", span=command.span)
target_mode: Literal["create", "replace"] = "create" if targets is not None else "replace"
target_names = (
[token.text for token in targets] if targets is not None
Expand Down Expand Up @@ -292,7 +303,7 @@ def _bind_recode(
if clause.match.kind == "else":
else_result = result
continue
rules.append(RecodeRule(_match(clause.match, source), result))
rules.append(RecodeRule(_match(clause.match, source, official_v03=official_v03), result))
unmatched = else_result or RecodeResult(
"system_missing" if target_mode == "create" else "copy"
)
Expand Down Expand Up @@ -332,6 +343,28 @@ def _bind_recode(
# replace intentionally preserves the existing variable metadata. A later
# VALUE LABELS command replaces value labels explicitly.
return operations, spans


def _expand_variables(
tokens: tuple[Token | VariableRangeSyntax, ...], variables: list[VariableDefinition],
) -> tuple[Token, ...]:
expanded: list[Token] = []
for token in tokens:
if isinstance(token, VariableRangeSyntax):
first, _ = _resolve(variables, token.first.text, token.first.span)
last = first
for endpoint in (token.last, *token.continuations):
next_index, _ = _resolve(variables, endpoint.text, endpoint.span)
if last > next_index:
raise frontend_error("invalid_variable_range", "TO endpoints are reversed in dictionary order.", span=token.span)
last = next_index
expanded.extend(Token("identifier", v.name, v.name, token.span) for v in variables[first:last + 1])
else:
_resolve(variables, token.text, token.span)
expanded.append(token)
return tuple(expanded)


def bind_spss_syntax(
program: SpssSyntaxProgram, schema: VariableSchema, *, input_alias: str = "parent",
) -> BoundTransformation:
Expand All @@ -345,6 +378,22 @@ def bind_spss_syntax(
operations: list[PlanOperation] = []
spans: list[SourceSpan] = []
for command in program.commands:
if program.official_v03:
if isinstance(command, (StringCommandSyntax, DeleteVariablesCommandSyntax)):
raise frontend_error("unsupported_spss_command", "Python schema commands are outside official Frontend 0.3.", span=command.span)
if isinstance(command, RecodeCommandSyntax):
command = replace(command, sources=_expand_variables(command.sources, variables))
elif isinstance(command, (FormatsCommandSyntax, VariableLevelCommandSyntax, VariableLabelsCommandSyntax)):
command = replace(command, assignments=tuple(
replace(assignment, variable=token)
for assignment in command.assignments
for token in _expand_variables((assignment.variable,), variables)
))
elif isinstance(command, ValueLabelsCommandSyntax):
command = replace(command, groups=tuple(
replace(group, variables=_expand_variables(group.variables, variables))
for group in command.groups
))
if isinstance(command, StringCommandSyntax):
for variable_token in command.variables:
if variable_token.text.startswith("__"):
Expand Down Expand Up @@ -383,7 +432,7 @@ def bind_spss_syntax(
del variables[index]
continue
if isinstance(command, RecodeCommandSyntax):
recodes, recode_spans = _bind_recode(command, variables)
recodes, recode_spans = _bind_recode(command, variables, official_v03=program.official_v03)
operations.extend(recodes)
spans.extend(recode_spans)
continue
Expand Down Expand Up @@ -515,6 +564,10 @@ def bind_spss_syntax(
"VALUE LABELS contains duplicate canonical codes.",
span=group.span, variable=variable.name,
)
if command.additive:
merged = {label.value.canonical_key(): label for label in variable.value_labels}
merged.update((label.value.canonical_key(), label) for label in labels)
labels = tuple(merged.values())
operation = ReplaceValueLabelsOperation(variable.name, labels)
operations.append(operation)
spans.append(group.span)
Expand Down
Loading