From 5a3c099f20615447f4bdbade0cb0f2efe3188132 Mon Sep 17 00:00:00 2001 From: Robert van Steen Date: Thu, 9 Apr 2026 11:51:40 +0200 Subject: [PATCH 1/3] Add Axiom v1 DSL playground and resolve all open spec questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser-based playground (Vite + Monaco) with from-scratch lexer, parser, type checker, and evaluator implementing the core Axiom DSL. Includes 6 example products (insurance, healthcare, tradespeople, hospitality, landlords, money type demo) with live evaluation, syntax highlighting, and diagnostics. Plugin system with lexer/checker/evaluator hooks, validated with axiom-money plugin providing £/€/$/¥ literals, type-safe currency arithmetic, and intrinsic overrides (round, max, min, sum for money types). Spec updates resolve all open questions: external data via tables (14.1), money as extension type (14.2), arbitrary-precision decimals (14.3), lazy evaluation with memoization (14.4), statically total error model with refined number types (14.5), plugin hook system (14.6), collection predicate narrowing (14.7), and recursion detection (14.10). Co-Authored-By: Claude Haiku 4.5 --- .gitignore | 7 + axiom-v1-spec.md | 1722 +++++++++++++++++ playground/index.html | 106 + playground/package-lock.json | 1128 +++++++++++ playground/package.json | 18 + playground/src/editor/language.ts | 115 ++ playground/src/editor/theme.ts | 43 + playground/src/examples/healthcare.axiom.ts | 148 ++ playground/src/examples/hospitality.axiom.ts | 828 ++++++++ playground/src/examples/industry_config.csv | 9 + playground/src/examples/insurance.axiom.ts | 153 ++ playground/src/examples/landlords.axiom.ts | 348 ++++ playground/src/examples/money.axiom.ts | 78 + .../src/examples/property_type_config.csv | 5 + playground/src/examples/tradespeople.axiom.ts | 367 ++++ playground/src/lang/ast.ts | 314 +++ playground/src/lang/checker.ts | 1273 ++++++++++++ playground/src/lang/diagnostics.ts | 23 + playground/src/lang/evaluator.ts | 702 +++++++ playground/src/lang/lexer.ts | 271 +++ playground/src/lang/parser.ts | 1009 ++++++++++ playground/src/lang/plugin.ts | 43 + playground/src/lang/types.ts | 132 ++ playground/src/main.ts | 269 +++ playground/src/plugins/money.ts | 233 +++ playground/src/utils/csv.ts | 61 + playground/src/vite-env.d.ts | 1 + playground/tsconfig.json | 17 + playground/vite.config.ts | 5 + 29 files changed, 9428 insertions(+) create mode 100644 axiom-v1-spec.md create mode 100644 playground/index.html create mode 100644 playground/package-lock.json create mode 100644 playground/package.json create mode 100644 playground/src/editor/language.ts create mode 100644 playground/src/editor/theme.ts create mode 100644 playground/src/examples/healthcare.axiom.ts create mode 100644 playground/src/examples/hospitality.axiom.ts create mode 100644 playground/src/examples/industry_config.csv create mode 100644 playground/src/examples/insurance.axiom.ts create mode 100644 playground/src/examples/landlords.axiom.ts create mode 100644 playground/src/examples/money.axiom.ts create mode 100644 playground/src/examples/property_type_config.csv create mode 100644 playground/src/examples/tradespeople.axiom.ts create mode 100644 playground/src/lang/ast.ts create mode 100644 playground/src/lang/checker.ts create mode 100644 playground/src/lang/diagnostics.ts create mode 100644 playground/src/lang/evaluator.ts create mode 100644 playground/src/lang/lexer.ts create mode 100644 playground/src/lang/parser.ts create mode 100644 playground/src/lang/plugin.ts create mode 100644 playground/src/lang/types.ts create mode 100644 playground/src/main.ts create mode 100644 playground/src/plugins/money.ts create mode 100644 playground/src/utils/csv.ts create mode 100644 playground/src/vite-env.d.ts create mode 100644 playground/tsconfig.json create mode 100644 playground/vite.config.ts diff --git a/.gitignore b/.gitignore index 0b7c985..c706bbd 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,10 @@ testbench.yaml /docs /coverage infection.log + +# Playground +playground/dist +playground/node_modules + +# Claude Code +.claude/ diff --git a/axiom-v1-spec.md b/axiom-v1-spec.md new file mode 100644 index 0000000..427c920 --- /dev/null +++ b/axiom-v1-spec.md @@ -0,0 +1,1722 @@ +# Axiom v1 — Language Specification + +Axiom is a statically-typed expression language for declarative, type-safe computation. It is designed for authored business logic: rating, pricing, eligibility, financial calculations, and domain rules. + +Axiom is not a general-purpose programming language. It is a small, reviewable language for computing values from typed inputs. + +The core guarantee of Axiom v1 is: + +> If a program parses, type-checks, and its runtime inputs validate, then evaluating it cannot fail. + +--- + +## 1. Design Principles + +1. **Expressions are the unit of authorship.** An Axiom program is a collection of named, typed expressions. Each expression has typed parameters and evaluates to exactly one value. + +2. **Reviewability beats cleverness.** The language prefers explicit, local constructs over compact or highly abstract ones. A business stakeholder should be able to read an Axiom program and follow the logic. + +3. **The core is pure.** Expressions, operators, coercions, constructors, collection forms, and expression calls are deterministic and side-effect free. + +4. **External data access is a boundary concern.** Host-provided functions for external data (CSV lookups, APIs, databases) are provided through the extension system, not as core language constructs. See §14. + +5. **Static proof first, runtime execution second.** Parsing, type checking, and runtime input validation establish the preconditions for safe execution. + +6. **The language stays narrow.** Axiom v1 intentionally excludes mutation, loops, user-defined higher-order functions, recursion as a control structure, exceptions, implicit IO, and arbitrary control abstractions. + +7. **Types are nominal.** Named variant types are identified by name, not structure. Accidental structural equivalence between unrelated types does not make them assignable. + +8. **Expressions return values.** Every named expression evaluates to a value. Meaningful domain outcomes (decline, referral, availability) are represented in that value, not in an out-of-band channel. + +9. **No null.** There is no `null` type. Meaningful absence is modeled with variants. + +10. **General-purpose constructs over domain-specific ones.** Language features should be useful beyond any single domain, even though the primary use case is insurance and financial computation. + +--- + +## 2. Non-Goals + +Axiom v1 does not provide: + +- mutation or assignment statements +- loops (`for`, `while`) +- recursion as a user-facing control structure +- exceptions or `try/catch` +- implicit IO or side effects +- hidden type coercions +- unrestricted anonymous unions +- `null` or optional/nullable types (`T?`) — use variants for meaningful absence +- string interpolation or concatenation — strings are codes and identifiers, not prose +- general-purpose higher-order functions (`map`, `filter`, `reduce`) — use collection forms instead + +--- + +## 3. Program Structure + +An Axiom program is a collection of top-level declarations. There is no "main" expression — any named expression may be targeted for execution. + +### 3.1 Expression Declarations + +A named expression has a name, typed parameters, an optional return type annotation, and a body. + +```axiom +BasePremium(sum_insured: number, rate: number): number { + sum_insured / 1000 * rate +} +``` + +Parameters may use inline record shapes: + +```axiom +Rating(quote: { sum_insured: number, trade: string }): number { + quote.sum_insured / 1000 * 1.5 +} +``` + +Or named record types: + +```axiom +Rating(exposure: Exposure, limit: number): number { + exposure.turnover / 1000 * limit +} +``` + +Return type annotations are optional. When omitted, the type checker infers the return type from the body expression. Annotations are required when: + +- The body produces a variant type that must be nominally bound to a declared type name +- The inferred type would be ambiguous + +### 3.2 Type Declarations + +#### Variant types + +A variant type is a closed set of tagged alternatives. Each alternative has a tag and an optional typed payload. + +```axiom +type CoverOutcome = + rated { + key: string, + name: string, + base_premium: number, + limit: number, + excess: number, + } + | not_available { reason: string } +``` + +#### Payload-less variants + +Variant alternatives may omit the payload when no data is needed: + +```axiom +type Status = active | suspended | cancelled + +type Decision = + approved { premium: number } + | declined + | referred { reason: string } +``` + +Payload-less tags are valid in both construction and pattern matching positions. At runtime they are represented as `{ _tag: "tagname" }` with no additional fields. + +#### Record types + +A record type declares a named shape without variant tags: + +```axiom +type Exposure = { + industry: string, + turnover: number, + number_of_employees: number, +} +``` + +Record types are used to structure input parameters and intermediate data. At runtime, record values are plain associative structures (dicts with known shapes). Member access on record-typed values is validated against the declared shape. + +### 3.3 Namespace Declarations + +Namespaces group related types, expressions, and constant symbols: + +```axiom +namespace Industry { + BuildingsFireClass(industry: string): string { + match industry { + "DRI-945" | "DRI-946" => "B", + _ => "Y", + } + } + + BaseExcess(industry: string): number { + match industry { + "DRI-945" => 500, + "DRI-946" => 250, + _ => 100, + } + } +} +``` + +Namespace members are accessed with qualified names: `Industry.BuildingsFireClass("DRI-945")`. + +Namespaces may contain: + +- **Expression declarations** — callable via `Namespace.Name(args)` +- **Type declarations** — referenced via `Namespace.TypeName` +- **Symbol declarations** — constant values with type annotations: `pi: number = 3.14159` + +### 3.4 Table Declarations + +A table declaration defines a named, typed, immutable list of records. The data comes from a companion artifact (e.g., a CSV file) that is bundled with the program version. + +```axiom +table industry_config: list({ + code: string, + buildings_fire_class: string, + pl_class: string, + pl_severity: number, + deep_frying_rate: number, + min_premium_default: number, +}) +``` + +Table declarations serve two purposes: + +1. **Schema declaration** — the type checker validates all access to the table's fields at compile time +2. **Artifact binding** — the runtime loads the companion data file, validates it against the declared schema, and provides the rows as an immutable list + +#### Design properties + +- **Declarative**: the language declares WHAT data exists and its shape; the runtime decides HOW to store and retrieve it (CSV scan, SQLite index, hash lookup) +- **Immutable**: table data cannot be modified by the program — it is fixed per program version +- **Pure**: tables are just typed lists — all existing list operations (`match ... in`, `collect ... in`, `any ... in`, `all ... in`) work on tables +- **Validated**: the runtime validates the artifact against the declared schema at load time, before any expression is evaluated + +#### Artifacts + +A program bundle consists of source code + artifacts. Artifacts are companion data files that are: + +- Version-locked to the program (changing the CSV means a new version) +- Validated against table schemas at deploy/load time +- Storage-format-agnostic from the language's perspective (CSV in development, SQLite in production — same program, same results) + +--- + +## 4. Type System + +### 4.1 Primitive Types + +| Type | Description | Literals | +|------|-------------|----------| +| `number` | Numeric values | `42`, `3.14`, `-1` | +| `string` | Text values | `"hello"`, `"DRI-945"` | +| `bool` | Boolean values | `true`, `false` | + +### 4.2 Compound Types + +| Type | Description | Literals | +|------|-------------|----------| +| `list(T)` | Ordered collection of elements | `[1, 2, 3]`, `["a", "b"]` | +| `dict(T)` | Key-value mapping | `{ key: "value", other: 42 }` | + +### 4.3 Named Types + +Named types are declared with `type` and are nominal — two types with different names are distinct even if structurally identical. + +**Variant types** model values that can be one of several tagged shapes: + +```axiom +type ProductOutcome = + offered { total: number, covers: dict(CoverOutcome) } + | declined { reasons: list(string) } + | referred { reasons: dict(string) } +``` + +**Record types** model values with a single known shape: + +```axiom +type Exposure = { industry: string, turnover: number } +``` + +### 4.4 Parameterized Types + +Types may accept parameters. The core provides `list(T)` and `dict(T)`. Extensions may define additional parameterized types such as `money(GBP)`. + +### 4.5 Type Assignability + +Axiom uses nominal typing for named types and structural compatibility for dicts and inline shapes. + +Rules: + +1. **Primitives**: same name = assignable. `number` to `number`, `string` to `string`, etc. +2. **Named variant types**: same name = same type. Structural equivalence is not sufficient. +3. **Lists**: `list(T)` is assignable to `list(U)` when `T` is assignable to `U`. +4. **Dicts/records**: structural compatibility — all required fields must exist with compatible types. +5. **`mixed`**: a special internal type assignable to any target. Used for intrinsic function parameters that accept any type. + +### 4.6 Type Coercion + +Explicit coercion with `as` is required — Axiom never performs hidden coercions. + +Valid coercions: + +| From | To | +|------|-----| +| `string` | `number` | +| `number` | `string` | + +Extensions may register additional coercion paths (e.g., `string` ↔ `money`, `number` ↔ `money`). + +### 4.7 Member Access + +Member access (`expr.property`) is valid when the type has a known shape declaring that property: + +- Record types: access validated against declared fields +- Dict literals with known shape: access validated against inferred fields +- Expression parameters with inline shapes: access validated against declared shape +- Variant types: access is legal **only** when the field exists on **every** alternative with the same type. Otherwise, narrow with `match` first. + +### 4.8 Index Access + +Index access (`expr[index]`) is valid on: + +- `list(T)[number]` → `T` +- `dict(shape)["key"]` → type of the keyed field, when the key is statically known + +--- + +## 5. Expressions + +All computation in Axiom is expressed through expressions. There are no statements. + +### 5.1 Literals + +```axiom +42 // number +3.14 // number +"hello" // string +true // bool +false // bool +[1, 2, 3] // list(number) +{ key: "value" } // dict +``` + +### 5.2 Identifiers and Member Access + +```axiom +turnover // identifier (resolved from scope) +exposure.industry // member access +exposure.address.postcode // chained member access +items[0] // index access +``` + +### 5.3 Arithmetic and Comparison + +```axiom +base_rate * (sum_insured / 1000) +total ** 2 +premium % 100 +turnover >= 50000 && not is_cancelled +trade not in ["asbestos", "demolition"] +``` + +See §6 for the full operator table. + +### 5.4 If / Then / Else + +`if/then/else` is an expression — both branches must produce a value. + +```axiom +if claims_count == 0 + then 0.95 + else 1.0 +``` + +Chained conditions with `else if`: + +```axiom +if claims == 0 + then 0.9 + else if claims <= 2 + then 1.0 + else if claims <= 5 + then 1.25 + else 1.50 +``` + +The condition must be `bool`. The `then` and `else` branches must produce compatible types. + +### 5.5 Match Expressions + +`match` dispatches on a subject value against a series of pattern arms: + +```axiom +match industry { + "DRI-945" | "DRI-946" => "B", + _ => "Y", +} +``` + +Subjectless match (condition-based): + +```axiom +match { + claims_count == 0 => 0.95, + claims_count <= 2 => 1.00, + claims_count == 3 => 1.20, + _ => 1.50, +} +``` + +Tuple match (multiple subjects): + +```axiom +match (region, tier) { + ("north", "premium") => 1.2, + ("south", _) => 1.0, + _ => 0.9, +} +``` + +Variant match with destructuring: + +```axiom +match cover { + rated { base_premium: p, limit: l } => p * l / 1000, + not_available { reason: r } => 0, +} +``` + +#### Match over lists (`match ... in`) + +`match binding in list` iterates over a list, binding each element to a variable, and returns the first arm that matches. The wildcard arm serves as a "no element matched" fallback. + +```axiom +match row in industry_config { + row.code == industry => row.pl_class, + _ => "", +} +``` + +This is the primary mechanism for querying table data. The arms are expression patterns evaluated with the binding in scope — any boolean condition that references the bound element. + +Semantics: +1. For each element in the list, bind it to the variable and try each non-wildcard arm in order +2. The first element+arm combination that matches returns the arm's expression value +3. If no element matches any arm, the wildcard arm (if present) provides the default + +```axiom +// Multi-condition lookup +match row in premium_bands { + turnover >= row.min && turnover < row.max => row.rate, + _ => 0, +} + +// Combining conditions: find matching row within a subset +match row in industry_config { + row.code in industries && row.pl_severity == worst => row.pl_class, + _ => "", +} +``` + +See §7 for all pattern forms. + +### 5.6 Expression Calls + +Named expressions are called by name with named arguments: + +```axiom +BuildingsCover(exposure: exposure, limit: 500000) +``` + +Qualified calls into namespaces: + +```axiom +Industry.BuildingsFireClass(industry: exposure.industry) +``` + +#### Named argument shorthand + +When a variable name matches the parameter name, the `: value` can be omitted: + +```axiom +// These are equivalent: +Rate(exposure: exposure, limit: limit) +Rate(exposure, limit) +``` + +#### Spread operator + +The spread operator `...` fills remaining unbound parameters by matching variable names in the caller's scope to parameter names in the callee: + +```axiom +Product(exposure: Exposure, claims: ClaimsHistory, pl_limit: number): ProductOutcome { + total_claims_loading = Claims.TotalLoading(...) + where total_claims_loading = Claims.TotalLoading( + number_of_claims: claims.number_of_claims, + total_claims_value: claims.total_claims_value, + ), + ... +} +``` + +Spread can be combined with explicit arguments — explicit arguments take precedence: + +```axiom +Rate(..., total_claims_loading: custom_value) +``` + +The type checker validates that every spread-resolved binding has a compatible type with the target parameter. Missing parameters after spread resolution produce a type error. + +### 5.7 Variant Construction + +Variant values are constructed with their tag name: + +```axiom +rated { + key: "PL", + name: "Public Liability", + base_premium: 500, + limit: 1000000, + excess: 250, +} +``` + +Payload-less variant construction: + +```axiom +active // payload-less tag as identifier +declined // payload-less tag as identifier +``` + +Qualified construction for disambiguation: + +```axiom +CoverOutcome.not_available { reason: "zone_blocked" } +``` + +#### Field shorthand + +When a variable name matches the field name: + +```axiom +// These are equivalent: +offered { covers: covers, subtotal: subtotal, total: total } +offered { covers, subtotal, total } +``` + +Shorthand and explicit fields can be mixed: + +```axiom +offered { covers, subtotal, total: round(raw_total, 2) } +``` + +### 5.8 Dict Literals + +```axiom +{ + pl: PublicLiability.Rate(exposure, limit: pl_limit), + bc: BuildingsContents.Rate(exposure, bc, risks), + bi: BusinessInterruption.Rate(exposure, bi), +} +``` + +Dict literals support the same field shorthand as variant construction. + +### 5.9 List Literals + +```axiom +[1, 2, 3] +["DRI-945", "DRI-946", "DRI-947"] +[rule1, rule2, rule3] +``` + +### 5.10 Where Clauses + +`where` introduces local bindings for intermediate values. Bindings are evaluated left-to-right and each binding can reference previous ones. + +```axiom +round(total, 2) + where base = exposure.turnover / 1000 * rate, + adjusted = base * claims_loading * experience_factor, + total = adjusted * group_relativity +``` + +Where clauses keep expression bodies readable by naming intermediate steps without requiring separate named expressions for every calculation: + +```axiom +Product(exposure: Exposure, claims: ClaimsHistory): ProductOutcome { + offered { + covers, + total_gross_premium: round(subtotal, 2), + total_net_premium: round(subtotal * (1 - commission_rate), 2), + commission_rate: 0.35, + currency: "GBP", + } + where total_claims_loading = Claims.TotalLoading(...), + covers = { + pl: PublicLiability.Rate(exposure, limit: pl_limit, total_claims_loading), + bc: BuildingsContents.Rate(exposure, bc, risks, total_claims_loading), + }, + subtotal = max( + sum collect rated { base_premium: p } in covers => p, + MinimumPremium(exposure.industry), + ) +} +``` + +### 5.11 Coercion + +```axiom +"42" as number // string to number +42 as string // number to string +``` + +### 5.12 Parenthesized Expressions + +Parentheses override operator precedence: + +```axiom +(base + adjustment) * factor +``` + +--- + +## 6. Operators + +### 6.1 Precedence Table + +From lowest to highest precedence: + +| Precedence | Operators | Associativity | Description | +|------------|-----------|---------------|-------------| +| 1 | `\|\|` | left | Logical OR | +| 2 | `&&` | left | Logical AND | +| 3 | `==`, `!=` | left | Equality | +| 4 | `<`, `>`, `<=`, `>=`, `in`, `not in` | left | Comparison and membership | +| 5 | `+`, `-` | left | Addition, subtraction | +| 6 | `*`, `/`, `%` | left | Multiplication, division, modulo | +| 7 | `**` | **right** | Exponentiation | + +### 6.2 Unary Operators + +| Operator | Operand | Result | Description | +|----------|---------|--------|-------------| +| `-` | `number` | `number` | Numeric negation | +| `not` | `bool` | `bool` | Logical negation | +| `!` | `bool` | `bool` | Logical negation (alias for `not`) | + +`not` is the preferred form for readability. `!` is accepted as an alias. + +### 6.3 Arithmetic Operators + +| Operator | Left | Right | Result | +|----------|------|-------|--------| +| `+` | `number` | `number` | `number` | +| `-` | `number` | `number` | `number` | +| `*` | `number` | `number` | `number` | +| `/` | `number` | `number` | `number` | +| `%` | `number` | `number` | `number` | +| `**` | `number` | `number` | `number` | + +Extensions may add operator rules for additional types (e.g., `money * number → money`). + +### 6.4 Comparison Operators + +| Operator | Operands | Result | +|----------|----------|--------| +| `==` | any, any | `bool` | +| `!=` | any, any | `bool` | +| `<` | any, any | `bool` | +| `>` | any, any | `bool` | +| `<=` | any, any | `bool` | +| `>=` | any, any | `bool` | + +### 6.5 Logical Operators + +| Operator | Left | Right | Result | +|----------|------|-------|--------| +| `&&` | `bool` | `bool` | `bool` | +| `\|\|` | `bool` | `bool` | `bool` | + +### 6.6 Membership Operators + +| Operator | Left | Right | Result | Description | +|----------|------|-------|--------|-------------| +| `in` | `T` | `list(T)` | `bool` | Element is in list | +| `not in` | `T` | `list(T)` | `bool` | Element is not in list | + +`not in` is a first-class operator, not sugar for `not (x in xs)`. It is parsed as a single two-token operator. + +### 6.7 Arrow Operator + +`=>` is used in match arms and collect bodies to separate patterns/conditions from result expressions. It is not a general-purpose operator. + +--- + +## 7. Patterns + +Patterns are used in `match` arms, `any`/`all` predicates, and `collect` forms. + +### 7.1 Wildcard Pattern + +Matches any value without binding: + +```axiom +_ => default_value +``` + +### 7.2 Literal Patterns + +Match by value equality: + +```axiom +42 => "exact number", +"brick" => 1.0, +true => "yes", +``` + +### 7.3 Alternative Patterns + +Match if any alternative matches: + +```axiom +"DRI-945" | "DRI-946" => "B", +1 | 2 | 3 => "low", +``` + +### 7.4 Range Patterns + +Match numeric ranges with inclusive `[]` and exclusive `()` bounds: + +```axiom +[0..100] // 0 ≤ x ≤ 100 (inclusive both) +(0..100) // 0 < x < 100 (exclusive both) +[0..100) // 0 ≤ x < 100 (inclusive left, exclusive right) +(0..100] // 0 < x ≤ 100 (exclusive left, inclusive right) +``` + +Open-ended ranges: + +```axiom +[5..] // x ≥ 5 +[..10] // x ≤ 10 +(0..) // x > 0 +``` + +### 7.5 Variant Patterns + +Match on variant tag and optionally destructure payload fields: + +```axiom +rated { base_premium: p, limit: l } => p + l, +not_available { reason: r } => r, +``` + +Field shorthand (bind to same name as field): + +```axiom +rated { base_premium, limit } => base_premium + limit, +``` + +Wildcard field binding (match field but don't bind): + +```axiom +rated { base_premium: _, limit: l } => l, +``` + +Payload-less variant patterns: + +```axiom +active => "active", +suspended => "on hold", +cancelled => "terminated", +``` + +Qualified variant patterns: + +```axiom +CoverOutcome.rated { base_premium: p } => p, +``` + +### 7.6 Tuple Patterns + +Match against tuple subjects: + +```axiom +match (region, tier) { + ("north", "premium") => 1.2, + ("south", _) => 1.0, + (_, _) => 0.9, +} +``` + +### 7.7 Expression Patterns + +In subjectless match, arms use boolean expression patterns: + +```axiom +match { + claims == 0 => 0.95, + claims <= 2 => 1.00, + _ => 1.50, +} +``` + +### 7.8 Exhaustiveness + +When matching on a variant type, the type checker verifies that all tags are covered. A wildcard `_` arm satisfies exhaustiveness for all remaining tags. + +```axiom +// Type error: non-exhaustive match — missing "referred" +match outcome { + offered { total: t } => t, + declined => 0, +} +``` + +--- + +## 8. Collection Forms + +Axiom provides pattern-aware collection operations over lists. These are narrower than general higher-order functions and are designed for working with lists of variants. + +### 8.1 `any` — Existential Predicate + +Returns `true` if at least one element matches the pattern: + +```axiom +any referred in covers +any rated { base_premium: _ } in covers +``` + +Type: `bool` + +### 8.2 `all` — Universal Predicate + +Returns `true` if every element matches the pattern: + +```axiom +all rated in covers +all ok { loading: _ } in rules +``` + +Type: `bool` + +### 8.3 `collect` — Pattern Map + +Evaluates the body for each matching element and returns a list of results: + +```axiom +collect referred { reason: r } in covers => r +collect rated { base_premium: p } in covers => p * 1.1 +``` + +Type: `list(T)` where `T` is the body type. + +### 8.4 Aggregate Collect + +Applies an aggregator function to the collected results. The aggregator is a registered intrinsic (e.g., `sum`, `product`, `max`, `min`). + +```axiom +product collect in rules { + ok { factor: f } => f, + _ => 1.0, +} + +sum collect in covers { + rated { base_premium: p } => p, + not_available => 0, +} +``` + +The arms must be exhaustive over the element type. Each arm body must produce a type compatible with the aggregator's input. + +### 8.5 Collect Over Lists (`collect ... in`) + +The binding form of `collect` binds each element to a name and transforms or filters it. + +**Map form** — transform every element: + +```axiom +collect prop in exposure.properties => PropertyRating.Total(prop) +// → [45.27, 101.95, 163.82] +``` + +**Filter form** — collect only matching elements using arms: + +```axiom +collect row in industry_config { + row.code in industries => row.pl_class, +} +// → ["A", "B", "C"] +``` + +Unlike `match ... in` (which returns the first match), `collect ... in` gathers all matches into a list. In the filter form, non-matching elements are skipped; a wildcard arm, if present, serves as a fallback for non-matching elements. + +Type: `list(T)` where `T` is the body/arm body type. + +### 8.6 Aggregate Collect Over Lists + +The aggregate form applies an aggregator to the collected results. + +**Map form** — aggregate every element: + +```axiom +sum collect prop in exposure.properties => PropertyRating.Total(prop) +// → 311.04 +``` + +**Filter form** — aggregate only matching elements: + +```axiom +max collect row in industry_config { + row.code in industries => row.deep_frying_rate, +} +// → 0.35 +``` + +This is the primary mechanism for multi-row aggregation — finding the worst-case, total, or average across matching rows. + +### 8.7 Collection Form Typing + +- The list operand must be `list(T)` for some `T`. +- Patterns are checked against `T`. +- `any`/`all` return `bool`. +- `collect` returns `list(U)` where `U` is the body type. +- Aggregate collect returns the aggregator's return type (typically `number`). +- In binding forms (`collect row in list => ...` and `collect row in list { ... }`), the binding variable has the element type `T` and is available in the body or all arm expressions. + +--- + +## 9. Intrinsic Functions + +Axiom v1 includes a small set of built-in functions. + +### 9.1 Core Intrinsics + +| Function | Signature | Description | +|----------|-----------|-------------| +| `round(value, decimals)` | `(number, number) → number` | Round to `decimals` decimal places | +| `len(collection)` | `(list \| dict) → number` | Number of elements/entries | +| `flatten(nested)` | `(list(list(T))) → list(T)` | Flatten one nesting level | +| `sum(collection)` | `(list(number)) → number` | Sum all elements | +| `product(collection)` | `(list(number)) → number` | Multiply all elements | +| `max(a, b, ...)` | `(number, number, ...) → number` | Maximum value (variadic or single list) | +| `min(a, b, ...)` | `(number, number, ...) → number` | Minimum value (variadic or single list) | + +### 9.2 Aggregators + +`sum`, `product`, `max`, and `min` are also usable as aggregator names in aggregate collect expressions: + +```axiom +sum collect in items { rated { premium: p } => p, _ => 0 } +product collect in rules { ok { factor: f } => f, _ => 1 } +max collect in options { available { rate: r } => r, _ => 0 } +``` + +--- + +## 10. Type Checking + +### 10.1 Inference Rules + +The type checker infers types bottom-up: + +| Expression | Inferred Type | +|------------|---------------| +| `42`, `3.14` | `number` | +| `"hello"` | `string` | +| `true`, `false` | `bool` | +| `[1, 2, 3]` | `list(number)` | +| `{ a: 1, b: "x" }` | `dict` with shape `{ a: number, b: string }` | +| `identifier` | declared type from scope | +| `object.property` | property type from object shape | +| `object[index]` | element/value type | +| `expr as type` | target type | +| `left OP right` | operator return type | +| `not expr` / `!expr` | `bool` | +| `-expr` | `number` | +| `if/then/else` | common branch type | +| `match` | common arm type | +| `Name(args)` | return type of named expression | +| `tag { fields }` | resolved variant type | +| `any P in xs` | `bool` | +| `all P in xs` | `bool` | +| `collect P in xs => body` | `list(T)` where `T` is body type | +| `agg collect in xs { ... }` | aggregator return type | +| `expr where bindings` | type of `expr` | + +### 10.2 Variant Resolution + +Variant tags are resolved in this order: + +1. **Contextual**: from the expected type (return type annotation, list element type, etc.) +2. **Qualified**: explicit `TypeName.tag` or `Namespace.tag` +3. **First-match**: search all declared types for a unique match + +If a tag appears in multiple types and is not qualified, the type checker reports an ambiguity error. + +### 10.3 Conditional Typing + +Both branches of `if/then/else` must produce compatible types. When the return type is annotated as a variant type, branches may produce different tags of that variant. + +### 10.4 Match Exhaustiveness + +For variant subjects, the type checker verifies all tags are covered. A wildcard `_` arm satisfies any uncovered tags. Missing coverage produces a diagnostic listing the uncovered tags. + +For non-variant subjects (numbers, strings), no exhaustiveness check is performed — a wildcard arm is recommended. + +### 10.5 Expression Call Checking + +For each expression call, the type checker validates: + +- All required parameters are provided (by name, shorthand, or spread) +- No unknown parameter names +- Argument types are assignable to parameter types +- Spread-resolved bindings have compatible types +- Return type (if annotated) is satisfied by the body + +### 10.6 Soundness Guarantees + +The type checker enforces: + +- Operator type validity +- Coercion validity +- Member access validity (field exists on type) +- Index access validity +- Match exhaustiveness (for variant subjects) +- Match arm type consistency +- Expression call argument validation (arity, names, types) +- Variant constructor validity (tag exists, fields correct, types match) +- No unresolved symbols +- No duplicate expression or type names + +--- + +## 11. Evaluation + +### 11.1 Direct AST Evaluation + +The evaluator walks AST nodes directly. There is no separate compiled intermediate representation in v1. + +### 11.2 Runtime Representation + +**Records and dicts** are plain associative structures: + +```json +{ "industry": "DRI-945", "turnover": 500000 } +``` + +**Variants** use a reserved `_tag` field: + +```json +{ + "_tag": "rated", + "key": "PL", + "name": "Public Liability", + "base_premium": 500, + "limit": 1000000, + "excess": 250 +} +``` + +**Payload-less variants**: + +```json +{ "_tag": "active" } +``` + +The `_tag` field is reserved. Authors may not declare payload fields named `_tag`. + +### 11.3 Scope + +Each expression call creates a new scope with parameters bound from arguments. `where` bindings extend the current scope for the duration of the `where` expression. Namespace symbols are available via qualified access. + +### 11.4 Evaluation Order + +- `if/then/else`: condition first, then only the taken branch +- `match`: subject first, then arms top-to-bottom until a match +- `where`: bindings left-to-right, then the body +- Operators: left-to-right (except `**` which is right-to-left) +- Expression calls: arguments evaluated, then body in new scope +- `&&` / `||`: short-circuit evaluation + +### 11.5 Match Dispatch + +- **Literal arms**: match by value equality +- **Range arms**: match by numeric range inclusion +- **Variant arms**: dispatch on the runtime `_tag` field; matched bindings introduced into arm scope +- **Wildcard arms**: match any value +- **Expression arms** (subjectless match): evaluate the expression as boolean; first truthy arm wins +- **Alternative arms**: match if any sub-pattern matches + +### 11.6 Collection Form Evaluation + +- `any P in xs` — iterate, return `true` on first match +- `all P in xs` — iterate, return `false` on first non-match +- `collect P in xs => body` — iterate, evaluate body for each match, collect into list +- `agg collect in xs { arms }` — iterate, evaluate matching arm body for each element, apply aggregator to resulting list + +--- + +## 12. Grammar + +```ebnf +program = { declaration } ; +declaration = type_decl | namespace_decl | expr_decl ; + +(* --- Type declarations --- *) + +type_decl = "type" UPPER_IDENT "=" ( record_shape | variant_alts ) ; +record_shape = "{" field_decl { "," field_decl } [ "," ] "}" ; +variant_alts = variant_alt { "|" variant_alt } ; +variant_alt = LOWER_IDENT [ "{" field_decl { "," field_decl } [ "," ] "}" ] ; +field_decl = LOWER_IDENT ":" type_expr ; + +(* --- Namespace declarations --- *) + +namespace_decl = "namespace" UPPER_IDENT "{" { ns_member } "}" ; +ns_member = type_decl | expr_decl | symbol_decl ; +symbol_decl = LOWER_IDENT ":" type_expr "=" expression ; + +(* --- Expression declarations --- *) + +expr_decl = UPPER_IDENT "(" [ param_list ] ")" [ ":" type_expr ] + "{" expression "}" ; +param_list = param { "," param } ; +param = LOWER_IDENT ":" ( type_expr | record_shape ) ; + +(* --- Type expressions --- *) + +type_expr = TYPE_KEYWORD [ "(" type_args ")" ] | UPPER_IDENT ; +type_args = expression { "," expression } ; +TYPE_KEYWORD = "number" | "string" | "bool" | "list" | "dict" ; + +(* --- Expressions --- *) + +expression = where_expr ; + +where_expr = or_expr [ "where" binding { "," binding } ] ; +binding = LOWER_IDENT "=" expression ; + +or_expr = and_expr { "||" and_expr } ; +and_expr = equality_expr { "&&" equality_expr } ; +equality_expr = comparison_expr { ( "==" | "!=" ) comparison_expr } ; +comparison_expr = additive_expr { ( "<" | ">" | "<=" | ">=" | "in" + | "not" "in" ) additive_expr } ; +additive_expr = multiplicative_expr { ( "+" | "-" ) multiplicative_expr } ; +multiplicative_expr = power_expr { ( "*" | "/" | "%" ) power_expr } ; +power_expr = unary_expr [ "**" power_expr ] ; +unary_expr = ( "not" | "!" | "-" ) unary_expr | postfix_expr ; +postfix_expr = primary { "." LOWER_IDENT + | "[" expression "]" + | "as" type_expr } ; + +primary = if_expr + | match_expr + | aggregate_collect + | collect_expr + | any_expr + | all_expr + | call_or_variant_ctor + | list_literal + | dict_literal + | NUMBER | STRING | BOOL + | LOWER_IDENT | UPPER_IDENT + | "(" expression ")" ; + +(* --- Control flow --- *) + +if_expr = "if" expression "then" expression + { "else" "if" expression "then" expression } + "else" expression ; + +match_expr = "match" [ match_subject ] "{" match_arm { "," match_arm } + [ "," ] "}" ; +match_subject = "(" expression { "," expression } ")" | expression ; +match_arm = pattern "=>" expression ; + +(* --- Collection forms --- *) + +any_expr = "any" pattern "in" expression ; +all_expr = "all" pattern "in" expression ; +collect_expr = "collect" pattern "in" expression "=>" expression ; +aggregate_collect = LOWER_IDENT "collect" "in" expression + "{" collect_arm { "," collect_arm } [ "," ] "}" ; +collect_arm = pattern "=>" expression ; + +(* --- Calls and construction --- *) + +call_or_variant_ctor = qualified_upper "(" [ arg_list ] [ "..." ] ")" + | LOWER_IDENT "{" [ entry_list ] "}" + | qualified_upper "." LOWER_IDENT "{" [ entry_list ] "}" ; +qualified_upper = UPPER_IDENT { "." UPPER_IDENT } ; +arg_list = arg { "," arg } ; +arg = LOWER_IDENT ":" expression | expression ; +entry_list = entry { "," entry } ; +entry = LOWER_IDENT ":" expression | LOWER_IDENT ; + +list_literal = "[" [ expression { "," expression } ] [ "," ] "]" ; +dict_literal = "{" [ entry_list ] [ "," ] "}" ; + +(* --- Patterns --- *) + +pattern = alt_pattern ; +alt_pattern = single_pattern { "|" single_pattern } ; +single_pattern = wildcard_pat | range_pat | variant_pat | tuple_pat + | literal_pat | expr_pat ; +wildcard_pat = "_" ; +literal_pat = NUMBER | STRING | BOOL ; +range_pat = ( "[" | "(" ) [ NUMBER ] ".." [ NUMBER ] ( "]" | ")" ) ; +variant_pat = [ qualified_upper "." ] LOWER_IDENT + [ "{" pat_binding { "," pat_binding } [ "," ] "}" ] ; +pat_binding = LOWER_IDENT [ ":" ( LOWER_IDENT | "_" ) ] ; +tuple_pat = "(" pattern "," pattern { "," pattern } ")" ; +expr_pat = expression ; + +(* --- Lexical --- *) + +UPPER_IDENT = [A-Z] [a-zA-Z0-9_]* ; +LOWER_IDENT = [a-z_] [a-zA-Z0-9_]* ; +NUMBER = [0-9]+ [ "." [0-9]+ ] ; +STRING = '"' ( [^"\\] | '\\' . )* '"' ; +BOOL = "true" | "false" ; +COMMENT = "//" [^\n]* ; +``` + +### 12.1 Keywords + +``` +type namespace if then else match not in as +any all collect where true false +``` + +### 12.2 Reserved + +``` +_tag // reserved field name (variant tag marker) +_ // wildcard pattern +``` + +--- + +## 13. Diagnostics + +All pipeline stages (parse, type check, lint, validate) produce diagnostics with a uniform structure: + +- **severity**: `error`, `warning`, or `info` +- **code**: stable dotted identifier (e.g., `type.unknown_tag`, `parse.unexpected_token`) +- **message**: human-readable description +- **location**: line, column, offset, length + +### 13.1 Diagnostic Code Categories + +| Prefix | Stage | Examples | +|--------|-------|----------| +| `parse.*` | Parser | `parse.unexpected_token`, `parse.unterminated_string` | +| `type.*` | Type checker | `type.unknown_tag`, `type.missing_field`, `type.argument_mismatch` | +| `lint.*` | Linter | `lint.unused_expression`, `lint.unreachable_arm` | +| `validation.*` | Input validation | `validation.missing_field`, `validation.type_mismatch` | + +### 13.2 Error Quality + +Diagnostics should: + +- Name expected and actual types concretely: `expected number, got string` +- For unknown variant tags, suggest the nearest valid tag +- For unknown fields, suggest the nearest valid field name +- For expression call errors, show the expected parameter signature +- For type mismatches in arguments, identify which parameter is wrong + +### 13.3 Parser Recovery + +The parser should recover at declaration boundaries and produce partial ASTs where possible, so the type checker can report additional errors on valid portions. + +--- + +## 14. Open Questions + +The following areas are acknowledged as important but not yet fully designed. Each will be addressed as a follow-up to this specification. + +### 14.1 ~~Boundary Functions and~~ External Data — RESOLVED + +**Resolution**: External data integrates via **table declarations** (§3.4) and **match/collect over lists** (§5.5, §8.5–8.6), not via boundary functions or plugins. + +**Key design decisions**: + +1. **Tables are core language constructs**, not an escape hatch to external systems. A `table` declaration defines a typed, immutable list of records. The data comes from a companion artifact (CSV, etc.) bundled with the program version. + +2. **The language is declarative about data access**. `match row in table { condition => value }` says WHAT to find; the runtime decides HOW (CSV scan, SQLite index, hash lookup). Same program, same results regardless of backing store. + +3. **No boundary functions needed for data lookup**. The original thinking assumed external data required a plugin system or callable escape hatch. Instead, tables make external data a first-class, pure, type-checked part of the language. + +4. **Failure model**: Tables are validated at load time against the declared schema. If the artifact doesn't match the schema, the program fails to load — not at evaluation time. The wildcard arm in `match ... in` handles "no row matched" as a value, not an error. + +5. **Multi-row queries** use `collect row in table { ... }` to gather all matching rows, composable with existing aggregators (`max`, `sum`, etc.). + +**Prototyped and validated** in the playground with a hospitality insurance product: single table replaces 12 source declarations, CSV artifact loading, single-industry and multi-industry lookups all verified. + +### 14.2 Money Type — RESOLVED + +Money is an **extension type** that plugs into the language via custom types, operator overloading, and coercion rules (see §14.6). It is not part of the core language, but v1 defines the syntax and semantics that money extensions must conform to. + +#### Literal Syntax + +Money literals use a currency prefix (symbol or ISO 4217 code) followed directly by a decimal number: + +```axiom +£100 // money(GBP) +£1234.56 // money(GBP) +€50.25 // money(EUR) +$200 // money(USD) +GBP100 // money(GBP) — 3-letter ISO code form +USD1500.00 // money(USD) +JPY10000 // money(JPY) +``` + +**Predefined symbol mapping:** + +| Symbol | Currency | +|--------|----------| +| `£` | GBP | +| `€` | EUR | +| `$` | USD | +| `¥` | JPY | + +All other currencies use the 3-letter ISO 4217 code prefix (e.g., `AUD250`, `CHF100.50`). + +#### Type + +`money(CURRENCY)` is a parameterized type. The currency is part of the type — `money(GBP)` and `money(USD)` are distinct types. + +```axiom +BasePremium(turnover: number, rate: number): money(GBP) { + £100 + turnover * rate // type error: number * number → number, not money +} +``` + +#### Arithmetic Rules + +| Expression | Result | Notes | +|------------|--------|-------| +| `money(C) + money(C)` | `money(C)` | Same currency required | +| `money(C) - money(C)` | `money(C)` | Same currency required | +| `money(C) * number` | `money(C)` | Scaling | +| `number * money(C)` | `money(C)` | Scaling (commutative) | +| `money(C) / number` | `money(C)` | Division by scalar | +| `money(C) / money(C)` | `number` | Ratio | +| `money(C1) + money(C2)` | **type error** | Cross-currency | +| `money(C) + number` | **type error** | Cannot mix money and number | + +Comparison operators (`==`, `!=`, `<`, `>`, `<=`, `>=`) work between values of the same `money(C)` type and return `bool`. + +#### Coercion + +```axiom +"100.50" as money(GBP) // string → money +150 as money(GBP) // number → money +``` + +#### Precision + +Money operations use arbitrary-precision decimal arithmetic (e.g., Brick\Money in PHP). Intermediate calculations preserve full precision. Rounding is explicit: + +```axiom +round(£100 / 3, 2) // → £33.33 +``` + +Currency-specific precision (e.g., 2 decimal places for GBP, 0 for JPY) is enforced by the runtime, not the language. + +#### Extension Mechanism + +A money extension registers: +1. **Type constructor** — `money(CURRENCY)` with currency validation +2. **Operator overloader** — arithmetic and comparison rules for money operands +3. **Coercion rules** — `string → money`, `number → money` conversion +4. **Literal tokenizer** — currency symbol/code recognition in the lexer + +The playground treats money as `number` for simplicity. A production implementation uses the money extension with Brick\Money (or equivalent) for precision and currency safety. + +### 14.3 Numeric Precision — RESOLVED + +**Resolution**: `number` is arbitrary-precision decimal. IEEE 754 floats are not conformant. See §19 for full specification. + +**Key decisions**: + +1. **Exact decimal representation**. All `number` values are stored and computed as arbitrary-precision decimals. Literals like `0.1` are parsed as exact decimal values, not float approximations. `0.1 + 0.2 == 0.3` must hold. + +2. **Arithmetic precision**. All operators (`+`, `-`, `*`, `/`, `%`, `**`) operate on exact decimals. Division produces exact results up to implementation-defined precision (recommended minimum: 20 significant digits). Explicit `round()` is required when a specific precision is needed. + +3. **Comparison is exact**. No epsilon tolerance — decimal comparison is bitwise on the decimal representation. This eliminates an entire class of subtle bugs in financial computation. + +4. **Coercion preserves precision**. `"1.005" as number` produces exact `1.005`, not a float approximation. JSON serialization uses string-encoded decimals to avoid JSON float precision loss. + +5. **Implementation requirements**. Conformant implementations must use an arbitrary-precision decimal library: `bcmath` or `brick/math` in PHP, `BigDecimal` in Java, `decimal.js` in JavaScript, `decimal` module in Python. + +6. **Playground exception**. The playground uses JavaScript floats as a pragmatic simplification for prototyping. It is explicitly non-conformant on precision — this is acceptable for syntax and type-system validation, but not for production use. + +### 14.4 Evaluation Model — Lazy vs Eager — RESOLVED + +**Resolution**: Lazy evaluation with memoization. Parameters and `where` bindings are evaluated on first reference, not at definition time. Each value is computed at most once and cached. + +**Key decisions**: + +1. **Expression arguments are lazy**. Parameters are evaluated on first reference, not at call time. If a branch never touches a parameter, it's never computed. A product that declines early skips all cover rating computations. + +2. **`where` bindings are lazy**. In `body where a = ..., b = ..., c = ...`, each binding is only evaluated when first demanded by `body` or by another binding. Source order does not determine evaluation order — only data dependencies do. + +3. **Memoized**. Each parameter and `where` binding is evaluated at most once per scope. First access computes and caches; subsequent accesses return the cached value. + +4. **Fresh memo table per call**. Each named expression call creates a child scope with a fresh memo table. There is no sharing of memoized values across expression calls. + +5. **Safety guarantee**. Lazy evaluation is safe because Axiom is pure — no side effects, no mutation, no I/O. The result of evaluating an expression is the same regardless of evaluation order. The only observable difference is performance. + +6. **Type checker validates all paths**. The type checker validates ALL branches and ALL expressions statically, regardless of whether they would be reached at runtime. Errors in unreached code are caught at check time, not hidden by lazy evaluation. + +**Playground exception**: The playground uses eager evaluation for implementation simplicity. This produces identical results (purity guarantees this) but may compute unnecessary values. + +### 14.5 Error Model — RESOLVED + +**Resolution**: If the type checker passes and input validation passes, evaluation cannot fail. The error model is **statically total** — every potential runtime error is either prevented by the type system or made safe by design. + +**Key decisions**: + +1. **Division by zero — prevented by refined number types**. The type system includes refined subtypes of `number`: `positive` (> 0), `non_negative` (>= 0), and `non_zero` (!= 0). Division requires the divisor to be `non_zero` or `positive`. If the divisor is typed as plain `number`, the type checker rejects it. See §6.1 in the revised spec for the full refinement type system. + +2. **Non-exhaustive match — type error**. The type checker requires match arms to be exhaustive: all variant tags covered, or a wildcard `_` present. Non-exhaustive match is a type error, not a runtime error. + +3. **Coercion failure — total by design**. `"abc" as number` returns `0`. Coercion is explicitly requested by the author and always succeeds. Input data is validated at load time, so runtime coercions typically operate on known-good data. + +4. **Index out of bounds — total by design**. `list[n]` where `n` is out of range returns `null`. The type checker warns on unguarded index access. In practice, Axiom programs rarely use direct indexing — `collect`, `match ... in`, `any`, and `all` iterate safely. + +5. **Missing input fields — rejected at input validation**. Input data is validated against declared parameter shapes before evaluation begins. + +6. **Table schema mismatch — rejected at load time**. Table data is validated against the declared schema when the program is loaded. + +**Refined number types** are the key design addition. They allow the type system to prove division safety, and they align naturally with the insurance domain where divisors are almost always inherently positive (counts, rates, sums insured). See §6.1 for the subtype hierarchy and §12.2 for inference and narrowing rules. + +### 14.6 Extension/Plugin System — RESOLVED + +**Resolution**: A plugin is a bundle of hooks across three pipeline stages: **lexer**, **checker**, and **evaluator**. The hooks are defined as abstract contracts (not tied to a specific language) and follow a "first plugin wins, or fall through to defaults" dispatch model. Prototyped and validated in the playground with the `axiom-money` plugin. + +#### Plugin Structure + +A plugin provides a name and optional hooks for each pipeline stage: + +``` +Plugin + name: string + lexer?: LexerHooks + checker?: CheckerHooks + evaluator?: EvaluatorHooks +``` + +All hooks are optional. A plugin may provide hooks for any combination of stages — e.g., a money plugin provides all three, while a plugin that only adds intrinsic functions might only provide evaluator hooks. + +#### Lexer Hooks + +The lexer hook allows a plugin to recognise custom literal syntax in the source text. + +``` +LexerHooks + tryTokenize(source: string, position: int) -> PluginToken | null + +PluginToken + tag: string // token identifier, e.g. "money" + value: string // display text, e.g. "£100.50" + payload: any // structured data carried to AST and evaluator + length: int // characters consumed from source +``` + +The lexer tries each plugin's `tryTokenize` at each position **before** the core tokenizer. If a plugin returns a token, it's emitted as a `PluginLiteral` and the lexer advances by `length`. If no plugin matches, the core tokenizer handles the position. + +**Example**: The money plugin's lexer hook recognises `£100.50` (symbol prefix) and `GBP100.50` (ISO code prefix), returning a token with tag `"money"` and a structured payload containing the amount and currency. + +#### Checker Hooks + +The checker hooks allow a plugin to participate in type inference and type checking. + +``` +CheckerHooks + inferLiteralType?(tag: string, payload: any) -> TypeSig | null + checkBinaryOp?(op: string, left: TypeSig, right: TypeSig) -> TypeSig | TypeError | null + checkCall?(name: string, argTypes: TypeSig[]) -> TypeSig | null +``` + +**`inferLiteralType`** — Given a plugin literal's tag and payload, return its type. The type checker calls this for every `PluginLiteral` node. Returns `null` to indicate the plugin doesn't handle this tag. + +**`checkBinaryOp`** — Given an operator and the inferred types of both operands, return: +- A `TypeSig` (the result type) if the plugin handles this combination +- A `TypeError` with a diagnostic message if the combination is a type error (e.g., cross-currency money addition) +- `null` to defer to the core type rules + +The checker tries each plugin **before** the core operator rules. This allows plugins to both extend (new type combinations) and restrict (type errors for invalid combinations) operator behaviour. + +**`checkCall`** — Given a function/intrinsic name and the inferred argument types, return the result type if the plugin overrides the built-in type checking for this call. Returns `null` to defer. This is used when a plugin-defined type changes the return type of a core intrinsic (e.g., `round(money, n)` → `money` instead of `number`). + +**Example**: The money plugin's checker infers `£100` as `money(GBP)`, defines `money(GBP) + money(GBP)` → `money(GBP)`, rejects `money(GBP) + number` with a descriptive error, and overrides `round(money, n)` to return `money`. + +#### Evaluator Hooks + +The evaluator hooks allow a plugin to handle operator evaluation and provide intrinsic function overrides. + +``` +EvaluatorHooks + supportsOp?(left: any, right: any, op: string) -> bool + evaluateOp?(left: any, right: any, op: string) -> any + intrinsics?: map(string -> function(...args) -> any | undefined) +``` + +**`supportsOp` / `evaluateOp`** — The evaluator checks each plugin before the core operator implementation. If `supportsOp` returns true, `evaluateOp` is called to produce the result. This allows plugins to define runtime behaviour for their types (e.g., money arithmetic that preserves currency metadata). + +**`intrinsics`** — A map of function names to implementations. When the evaluator encounters a call to a registered intrinsic, the plugin's implementation is tried first. If it returns `undefined`, the evaluator falls through to the built-in implementation. This allows plugins to override built-in intrinsics for specific argument types (e.g., `sum` over a list of money values). + +**Example**: The money plugin's evaluator handles `money + money`, `money * number`, etc. at runtime, preserving the `{ amount, currency }` structure. It overrides `round`, `max`, `min`, and `sum` to work with money values. + +#### Dispatch Order + +Plugins are registered in a defined order. At each hook point: + +1. Plugins are tried in registration order +2. The first plugin that returns a non-null result wins +3. If no plugin handles the hook, the core implementation runs + +This means a plugin registered earlier takes priority. In practice, plugins operate on disjoint types (money, interval, etc.) so ordering rarely matters. + +#### Boot Sequence + +``` +for each plugin in registered_plugins: + register plugin.lexer hooks with lexer + register plugin.checker hooks with checker + register plugin.evaluator hooks with evaluator +``` + +Plugin registration happens once at program load time, before parsing. The set of active plugins is immutable for the lifetime of a program evaluation. + +#### Scope of Extensions + +With table declarations resolving external data (§14.1), the scope of plugins is focused: + +| Extension point | What it provides | Example | +|---|---|---| +| Custom literal syntax | New token forms in source text | `£100`, `(0..1000]` | +| Parameterized types | New types with parameters | `money(GBP)`, `interval(number)` | +| Operator overloading | Type rules + runtime behaviour for new types | `money + money`, `money * number` | +| Intrinsic overrides | Type-specific behaviour for core functions | `round(money)`, `sum(list(money))` | +| Named types | Shared domain vocabulary under a namespace | `insurance.CoverOutcome` | +| Pattern matchers | Custom match pattern forms | interval patterns | + +Plugins do **not** provide: +- New syntax forms beyond literals (no new operators, no new keywords) +- Mutable state or side effects +- External data access (handled by tables, §14.1) + +#### Validated with Prototype + +The `axiom-money` plugin was implemented in the playground, demonstrating all extension points working end-to-end: custom lexer tokens (`£500`, `EUR1000`), type inference and operator checking (`money(GBP) + money(GBP)` → `money(GBP)`, `money + number` → type error), intrinsic overrides (`round`, `max`, `min`, `sum`), and runtime evaluation. The healthcare and tradespeople examples use 116 and 88 money tokens respectively with zero type errors. + +### 14.7 Collection Predicate Narrowing — RESOLVED + +**Resolution**: Yes, `any`/`all` predicates narrow variant types in conditional branches. This is a v1 feature with a full design specified in §12.5 of the revised spec. + +**Rules**: + +For `if any P in xs then A else B` where `P` is a variant pattern and `xs` has variant element type `T`: +- `A` is checked under the original type of `xs` +- `B` is checked with `xs` narrowed to `list(T minus matched alternatives)` + +For `if all P in xs then A else B`: +- `A` is checked with `xs` narrowed to `list(matched alternatives only)` +- `B` is checked under the original type of `xs` + +**Primary use case**: In the `else` branch of `if any referred in covers`, `covers` is narrowed to exclude `referred`. An aggregated collect over `rated`/`not_available` in that branch is exhaustive without a wildcard arm: + +```axiom +if any referred in covers + then referred { reasons: collect referred { reason: r } in covers => r } + else offered { + total: sum collect in covers { + rated { premium: p } => p, + not_available => 0, + // no "referred" arm needed — type system knows it's excluded + }, + } +``` + +This narrowing applies only to the direct recognised forms. Logically equivalent derived expressions do not trigger narrowing in v1. The playground does not yet implement this, but the spec design is complete. + +### 14.8 Pretty Printer and Round-Trip — DEFERRED + +**Status**: Desirable but not blocking v1. The revised spec defines the round-trip property `parse(prettyPrint(ast))` yields an equivalent AST (§15), but implementation and testing are deferred. + +### 14.9 Namespace `use` / Imports — DEFERRED + +**Status**: Not in scope for v1. v1 uses single-file programs with fully qualified namespace references. Multi-file programs and imports are a v2 concern. + +### 14.10 Recursion Detection — RESOLVED + +**Resolution**: Yes, the type checker detects and rejects circular call dependencies between named expressions. Mutual recursion is a type error — Axiom does not support recursion as a control structure. This is specified as a soundness check in §12.8 of the revised spec. + +--- + +## 15. Full Example + +The following demonstrates most language features in a realistic insurance rating scenario: + +```axiom +// --- Input record types --- + +type Exposure = { + industry: string, + number_of_employees: number, + turnover: number, + is_sole_trader: bool, + years_experience: number, +} + +type ClaimsHistory = { + number_of_claims: number, + total_claims_value: number, +} + +type RiskScores = { + flood_risk: number, + theft_risk: number, + terrorism_risk: number, +} + +// --- Outcome types --- + +type CoverOutcome = + rated { + key: string, + name: string, + base_premium: number, + limit: number, + excess: number, + } + | not_available { reason: string } + +type ProductOutcome = + offered { + covers: dict(CoverOutcome), + subtotal: number, + minimum_premium: number, + total_gross_premium: number, + total_net_premium: number, + commission_rate: number, + currency: string, + } + | declined { reasons: list(string) } + | referred { reasons: dict(string) } + +// --- Industry configuration --- + +namespace Industry { + BaseRate(industry: string): number { + match industry { + "DRI-945" => 0.85, + "DRI-946" => 1.10, + "DRI-947" => 0.65, + _ => 1.00, + } + } + + BaseExcess(industry: string): number { + match industry { + "DRI-945" => 500, + "DRI-946" => 250, + _ => 100, + } + } +} + +// --- Claims loading --- + +namespace Claims { + TotalLoading(number_of_claims: number, total_claims_value: number): number { + FrequencyLoading(number_of_claims) * SeverityLoading(total_claims_value) + } + + FrequencyLoading(number_of_claims: number): number { + match number_of_claims { + 0 => 1, + 1 => 1.1, + 2 => 1.25, + [3..5] => 1.5, + _ => 2.0, + } + } + + SeverityLoading(total_claims_value: number): number { + match total_claims_value { + [0..10000] => 1, + (10000..50000] => 1.15, + (50000..100000] => 1.35, + _ => 1.6, + } + } +} + +// --- Cover rating --- + +namespace PublicLiability { + Rate(exposure: Exposure, limit: number, total_claims_loading: number): CoverOutcome { + rated { + key: "PL", + name: "Public Liability", + base_premium: round(base * total_claims_loading, 2), + limit, + excess: Industry.BaseExcess(industry: exposure.industry), + } + where base = exposure.turnover / 1000 + * Industry.BaseRate(industry: exposure.industry) + * limit / 1000000 + } +} + +// --- Product entry point --- + +Product(exposure: Exposure, claims: ClaimsHistory, pl_limit: number): ProductOutcome { + offered { + covers, + subtotal, + minimum_premium: 500, + total_gross_premium: round(max(subtotal, 500), 2), + total_net_premium: round(max(subtotal, 500) * (1 - 0.35), 2), + commission_rate: 0.35, + currency: "GBP", + } + where total_claims_loading = Claims.TotalLoading( + number_of_claims: claims.number_of_claims, + total_claims_value: claims.total_claims_value, + ), + covers = { + pl: PublicLiability.Rate(exposure, limit: pl_limit, total_claims_loading), + }, + subtotal = sum collect rated { base_premium: p } in covers => p +} +``` + +--- + +## 16. Summary + +Axiom v1 is a typed expression language for authored business computation. Its core is: + +- **Named expressions** with typed parameters as the unit of authorship +- **Composition by calling** — expressions call other expressions +- **Where clauses** for naming intermediate values within an expression body +- **Spread** and **shorthand notation** for reducing argument-passing boilerplate +- **Record types** for structuring input data with named shapes +- **Variant types** — closed tagged unions with optional payloads, including payload-less tags +- **Nominal typing** for named types, structural compatibility for dicts +- **`if/then/else`** for boolean conditions, **`match`** for multi-arm dispatch and variant narrowing +- **Pattern matching** with literals, wildcards, ranges, alternatives, variants, and tuples +- **Collection forms** (`any`, `all`, `collect`, aggregate `collect`) for working with lists of variants +- **Explicit coercion** with `as` — no hidden type conversions +- **Namespaces** for organising related types and expressions +- **A small set of intrinsics** (`round`, `len`, `flatten`, `sum`, `product`, `max`, `min`) +- **No null**, no mutation, no loops, no side effects +- **A strong execution guarantee**: if it parses, type-checks, and validates, it cannot fail diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 0000000..13f2b81 --- /dev/null +++ b/playground/index.html @@ -0,0 +1,106 @@ + + + + + + Axiom Playground + + + +
+
+

Axiom Playground

+
+ + + + +
+
+
+
+
+
+
+
+
+

Input (JSON)

+ +
+
+

Output

+

+        
+
+

Diagnostics

+
No issues
+
+
+
+
+ + + diff --git a/playground/package-lock.json b/playground/package-lock.json new file mode 100644 index 0000000..e0297fd --- /dev/null +++ b/playground/package-lock.json @@ -0,0 +1,1128 @@ +{ + "name": "axiom-playground", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "axiom-playground", + "version": "0.1.0", + "dependencies": { + "monaco-editor": "^0.52.2" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/playground/package.json b/playground/package.json new file mode 100644 index 0000000..0232601 --- /dev/null +++ b/playground/package.json @@ -0,0 +1,18 @@ +{ + "name": "axiom-playground", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "monaco-editor": "^0.52.2" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/playground/src/editor/language.ts b/playground/src/editor/language.ts new file mode 100644 index 0000000..cc0d900 --- /dev/null +++ b/playground/src/editor/language.ts @@ -0,0 +1,115 @@ +import * as monaco from 'monaco-editor'; + +export function registerAxiomLanguage() { + monaco.languages.register({ id: 'axiom' }); + + monaco.languages.setLanguageConfiguration('axiom', { + comments: { lineComment: '//' }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"', notIn: ['string'] }, + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + ], + indentationRules: { + increaseIndentPattern: /[{([].*$/, + decreaseIndentPattern: /^\s*[})\]]/, + }, + }); + + monaco.languages.setMonarchTokensProvider('axiom', { + keywords: [ + 'type', 'namespace', 'source', 'table', 'if', 'then', 'else', 'match', + 'not', 'in', 'as', 'any', 'all', 'collect', 'where', + ], + + typeKeywords: [ + 'number', 'string', 'bool', 'list', 'dict', 'money', + ], + + constants: ['true', 'false'], + + operators: [ + '=', '==', '!=', '<', '>', '<=', '>=', + '&&', '||', '!', '+', '-', '*', '/', '%', '**', + '=>', '|', '..', '...', + ], + + symbols: /[=>/, 'delimiter.arrow'], + [/[=> match limit { 500000 => £1125, 1000000 => £1500, 2000000 => £1950, 3000000 => £2175, 4000000 => £2400, 5000000 => £2475, _ => £0 }, + "DRI-129" => match limit { 500000 => £750, 1000000 => £1000, 2000000 => £1300, 3000000 => £1450, 4000000 => £1600, 5000000 => £1650, _ => £0 }, + "DRI-138" => match limit { 500000 => £225, 1000000 => £300, 2000000 => £390, 3000000 => £435, 4000000 => £480, 5000000 => £495, _ => £0 }, + "DRI-139" => match limit { 500000 => £263, 1000000 => £350, 2000000 => £455, 3000000 => £508, 4000000 => £560, 5000000 => £578, _ => £0 }, + "DRI-140" => match limit { 500000 => £225, 1000000 => £300, 2000000 => £390, 3000000 => £435, 4000000 => £480, 5000000 => £495, _ => £0 }, + "DRI-170" => match limit { 500000 => £75, 1000000 => £100, 2000000 => £130, 3000000 => £145, 4000000 => £160, 5000000 => £165, _ => £0 }, + "DRI-198" => match limit { 500000 => £938, 1000000 => £1250, 2000000 => £1625, 3000000 => £1813, 4000000 => £2000, 5000000 => £2063, _ => £0 }, + "DRI-236" => match limit { 500000 => £45, 1000000 => £60, 2000000 => £78, 3000000 => £87, 4000000 => £96, 5000000 => £99, _ => £0 }, + "DRI-247" => match limit { 500000 => £563, 1000000 => £750, 2000000 => £975, 3000000 => £1088, 4000000 => £1200, 5000000 => £1238, _ => £0 }, + "DRI-253" => match limit { 500000 => £900, 1000000 => £1200, 2000000 => £1560, 3000000 => £1740, 4000000 => £1920, 5000000 => £1980, _ => £0 }, + "DRI-263" => match limit { 500000 => £750, 1000000 => £1000, 2000000 => £1300, 3000000 => £1450, 4000000 => £1600, 5000000 => £1650, _ => £0 }, + "DRI-295" => match limit { 500000 => £900, 1000000 => £1200, 2000000 => £1560, 3000000 => £1740, 4000000 => £1920, 5000000 => £1980, _ => £0 }, + "DRI-318" => match limit { 500000 => £563, 1000000 => £750, 2000000 => £975, 3000000 => £1088, 4000000 => £1200, 5000000 => £1238, _ => £0 }, + "DRI-319" => match limit { 500000 => £49, 1000000 => £65, 2000000 => £85, 3000000 => £94, 4000000 => £104, 5000000 => £107, _ => £0 }, + _ => £0, + } +} + +// Inline excess lookup table (from excess.csv) +ExcessLookup(industry: string): money(GBP) { + match industry { + "DRI-106" => £2000, + "DRI-129" => £1000, + "DRI-138" => £500, + "DRI-139" => £250, + "DRI-140" => £500, + "DRI-170" => £150, + "DRI-198" => £1000, + "DRI-236" => £500, + "DRI-247" => £1000, + "DRI-253" => £2000, + "DRI-263" => £1000, + "DRI-295" => £2000, + "DRI-318" => £1000, + "DRI-319" => £250, + _ => £0, + } +} + +// Industry-specific endorsements +IndustryEndorsements(industry: string): list(Endorsement) { + match industry { + "DRI-129" => [ + applied { code: "END-01", title: "Chiropodist / Podiatrist Exclusion" }, + ], + "DRI-138" | "DRI-139" | "DRI-140" => [ + applied { code: "END-03", title: "Direct Access Extension" }, + applied { code: "END-12", title: "Teeth Whitening Condition" }, + ], + "DRI-247" => [ + applied { code: "END-08", title: "Opticians / Optical Exclusion" }, + ], + "DRI-263" => [ + applied { code: "END-10", title: "Professional Sports Exclusion" }, + applied { code: "END-11", title: "Spinal Joint Manipulation Exclusion" }, + ], + "DRI-318" => [ + applied { code: "END-02", title: "Diagnostic and Interpretation Exclusion" }, + ], + _ => [], + } +} + +// Cover calculation +HealthcareProfessional( + industry: string, + limit: number, + commission_rate: number, +): CoverOutcome { + if PremiumLookup(industry: industry, limit: limit) == £0 + then referred { reason: "Industry not rated" } + else rated { + gross_premium: PremiumLookup(industry: industry, limit: limit), + net_premium: round(PremiumLookup(industry: industry, limit: limit) * (1 - commission_rate), 2), + commission: round(PremiumLookup(industry: industry, limit: limit) * commission_rate, 2), + excess: ExcessLookup(industry: industry), + limit: limit, + inquest_costs: £25000, + endorsements: IndustryEndorsements(industry: industry), + } +} + +// Product assembly — wraps cover outcome with product-level metadata +// Propagates cover referrals as product declines +BuildProduct(cover: CoverOutcome): ProductOutcome { + match cover { + referred { reason } => declined { reasons: [reason] }, + _ => offered { + cover: cover, + umr: "B1792SPR2500004A", + currency: "GBP", + jurisdiction: "Great Britain, Northern Ireland, Isle of Man and Channel Islands", + }, + } +} + +// Product entry point — validates exposure constraints, then rates +Product( + industry: string, + limit: number, + number_of_employees: number, +): ProductOutcome { + if number_of_employees > 1 + then declined { reasons: ["Maximum 1 employee allowed"] } + else BuildProduct(cover: HealthcareProfessional( + industry: industry, + limit: limit, + commission_rate: 0.33, + )) +} +`; + +// Scenario: Dental Hygienist (DRI-138) at £1,000,000 limit, sole trader +export const HEALTHCARE_INPUT = { + industry: "DRI-138", + limit: 1000000, + number_of_employees: 1, +}; diff --git a/playground/src/examples/hospitality.axiom.ts b/playground/src/examples/hospitality.axiom.ts new file mode 100644 index 0000000..23cbe91 --- /dev/null +++ b/playground/src/examples/hospitality.axiom.ts @@ -0,0 +1,828 @@ +import industryConfigCSV from './industry_config.csv?raw'; +import { parseCSV } from '../utils/csv'; + +export const HOSPITALITY_EXAMPLE = `// AIG Hospitality V2 +// Translated from Abacus ProductSchemeBuilder + 6 CoverSchemeBuilders +// Subset: 4 industries, simplified BC (no alarm/construction rules) + +// --- Input record types --- + +type Exposure = { + industry: string, + number_of_employees: number, + turnover: number, + is_sole_trader: bool, + years_experience: number, +} + +type ClaimsHistory = { + number_of_claims: number, + total_claims_value: number, +} + +type RiskScores = { + flood_risk: number, + theft_risk: number, + terrorism_risk: number, +} + +type BCConfig = { + buildings_limit: number, + contents_limit: number, + stock_limit: number, + listed_type: string, + has_outdoor_play: bool, + has_functions: bool, + number_of_beds: string, + has_heavy_deep_frying: bool, +} + +type BIConfig = { + basis_of_cover: string, + basis_of_cover_limit: number, + indemnity_months: number, + rent_receivable_limit: number, + loss_of_licence_limit: number, +} + +// --- Outcome types --- + +type CoverOutcome = + rated { + key: string, + name: string, + base_premium: number, + limit: number, + excess: number, + } + | not_available { reason: string } + +type ProductOutcome = + offered { + covers: dict(CoverOutcome), + subtotal: number, + minimum_premium: number, + total_gross_premium: number, + total_net_premium: number, + commission_rate: number, + currency: string, + } + | declined { reasons: list(string) } + | referred { reasons: dict(string) } + +// --- Industry configuration (from industry_config.csv, 28 columns x 8 industries) --- +// Table declaration — typed companion data, loaded from CSV artifact at deploy time + +table industry_config: list({ + code: string, + buildings_fire_class: string, + contents_fire_class: string, + contents_theft_class: string, + stock_theft_class: string, + bi_fire_class: string, + pl_class: string, + pl_severity: number, + el_class: string, + el_severity: number, + flood_banding: string, + loss_of_licence_class: string, + deep_frying_rate: number, + min_premium_claims_free: number, + min_premium_default: number, +}) + +// Each lookup is a pure expression that searches the table +namespace Industry { + BuildingsFireClass(industry: string): string { + match row in industry_config { row.code == industry => row.buildings_fire_class, _ => "" } + } + ContentsFireClass(industry: string): string { + match row in industry_config { row.code == industry => row.contents_fire_class, _ => "" } + } + ContentsTheftClass(industry: string): string { + match row in industry_config { row.code == industry => row.contents_theft_class, _ => "" } + } + StockTheftClass(industry: string): string { + match row in industry_config { row.code == industry => row.stock_theft_class, _ => "" } + } + BIFireClass(industry: string): string { + match row in industry_config { row.code == industry => row.bi_fire_class, _ => "" } + } + PLClass(industry: string): string { + match row in industry_config { row.code == industry => row.pl_class, _ => "" } + } + ELClass(industry: string): string { + match row in industry_config { row.code == industry => row.el_class, _ => "" } + } + FloodBanding(industry: string): string { + match row in industry_config { row.code == industry => row.flood_banding, _ => "" } + } + LossOfLicenceClass(industry: string): string { + match row in industry_config { row.code == industry => row.loss_of_licence_class, _ => "" } + } + DeepFryingRate(industry: string): number { + match row in industry_config { row.code == industry => row.deep_frying_rate, _ => 0 } + } + MinPremiumClaimsFree(industry: string): number { + match row in industry_config { row.code == industry => row.min_premium_claims_free, _ => 0 } + } + MinPremiumDefault(industry: string): number { + match row in industry_config { row.code == industry => row.min_premium_default, _ => 0 } + } + + // --- Multi-industry lookups (worst-case across selected industries) --- + + // Classification fields: find the row with the highest severity + WorstPLClass(industries: list(string)): string { + match row in industry_config { + row.code in industries && row.pl_severity == worst => row.pl_class, + _ => "", + } + where worst = max collect row in industry_config { + row.code in industries => row.pl_severity, + } + } + + WorstELClass(industries: list(string)): string { + match row in industry_config { + row.code in industries && row.el_severity == worst => row.el_class, + _ => "", + } + where worst = max collect row in industry_config { + row.code in industries => row.el_severity, + } + } + + // Numeric fields: direct aggregation + MaxDeepFryingRate(industries: list(string)): number { + max collect row in industry_config { + row.code in industries => row.deep_frying_rate, + } + } + + MaxMinPremiumDefault(industries: list(string)): number { + max collect row in industry_config { + row.code in industries => row.min_premium_default, + } + } +} + +// --- Claims loading system (product-level, shared across all covers) --- + +namespace Claims { + // Years trading coefficient and loading (from years_trading_coefficient_and_loads.csv) + // Capped at min(5, yearsExperience) + YearsTradingLoading(is_sole_trader: bool, years_experience: number): number { + match (is_sole_trader, min(5, years_experience)) { + (false, [0..1]) => 1, + (false, 2) => 0.5, + (false, 3) => 0.25, + (false, 4) => 0, + (false, 5) => -0.25, + (true, [0..2]) => 1, + (true, 3) => 0.25, + (true, 4) => 0, + (true, 5) => -0.25, + _ => 0, + } + } + + // Coefficient letter determines which column to use in claims cross-lookup + Coefficient(is_sole_trader: bool, years_experience: number): string { + match (is_sole_trader, min(5, years_experience)) { + (false, [0..1]) | (true, [0..1]) => "A", + (false, 2) | (true, 2) => "B", + (false, 3) | (true, 3) => "C", + (false, 4) | (true, 4) => "D", + (false, 5) | (true, 5) => "E", + _ => "A", + } + } + + // Claims x years-trading cross-lookup (from claims_years_trading_loadings.csv) + // Row = number_of_claims, column = coefficient letter + ClaimsYearsTradingLoading(number_of_claims: number, coefficient: string): number { + match (number_of_claims, coefficient) { + (0, "A") | (0, "B") => 0, + (0, "C") => -0.1, + (0, "D") | (0, "E") => -0.2, + (1, "A") => 1, + (1, "B") => 0.1, + (1, "C") => 0.1, + (1, "D") | (1, "E") => 0.05, + (2, "A") => 5, + (2, "B") => 0.675, + (2, "C") => 0.2, + (2, "D") | (2, "E") => 0.125, + (3, "A") => 6, + (3, "B") => 1.75, + (3, "C") => 0.675, + (3, "D") | (3, "E") => 0.3, + (4, "A") | (4, "B") => 10, + (4, "C") => 1.75, + (4, "D") => 0.68, + (4, "E") => 0.675, + (5, "A") | (5, "B") => 25, + (5, "C") => 10, + (5, "D") | (5, "E") => 1.75, + _ => 100, + } + } + + // Claims value loading (from claims_value_loadings.csv, range-based) + ClaimsValueLoading(total_claims_value: number): number { + match total_claims_value { + [0..400) => 0, + [400..600) => 0.1, + [600..700) => 0.2, + [700..800) => 0.3, + [800..900) => 0.4, + [900..1000) => 0.5, + [1000..2000) => 0.75, + [2000..3000) => 1.25, + [3000..4000) => 2, + [4000..5000) => 3.25, + [5000..6000) => 5.25, + [6000..8000) => 8.5, + [8000..9000) => 9.5, + [9000..10000) => 10, + [10000..) => 20, + _ => 0, + } + } + + // Total claims loading: sum of all three components + TotalLoading(exposure: Exposure, claims: ClaimsHistory): number { + YearsTradingLoading(exposure.is_sole_trader, exposure.years_experience) + + ClaimsYearsTradingLoading(claims.number_of_claims, coefficient) + + ClaimsValueLoading(claims.total_claims_value) + where coefficient = Coefficient(exposure.is_sole_trader, exposure.years_experience) + } +} + +// --- Minimum premium logic --- + +MinimumPremium(exposure: Exposure, claims: ClaimsHistory, bc: BCConfig): number { + if exposure.years_experience >= 2 && claims.number_of_claims == 0 && not bc.has_heavy_deep_frying + then Industry.MinPremiumClaimsFree(exposure.industry) + else Industry.MinPremiumDefault(exposure.industry) +} + +// --- Public Liability (PL) — mandatory --- + +namespace PublicLiability { + // Base rate by PL classification (from base_rates.csv) + BaseRate(classification: string): number { + match classification { + "A" | "B" => 0.01, + "C" => 0.0175, + _ => 0.01, + } + } + + // Turnover size discount (from discounts.csv) + TurnoverDiscount(turnover: number): number { + match turnover { + [0..100000) => 0, + [100000..150000) => -0.05, + [150000..200000) => -0.075, + [200000..250000) => -0.1, + _ => 0, + } + } + + // Limit loading (from loadings.csv) + LimitLoading(limit: number): number { + match limit { + 1000000 => -0.1, + 2000000 => 0, + 5000000 => 0.25, + _ => 0, + } + } + + Rate(exposure: Exposure, limit: number, total_claims_loading: number): CoverOutcome { + rated { + key: "PL", + name: "Public and products liability", + base_premium: (exposure.turnover / 100) * (base_rate * (1 + total_loads)), + limit: limit, + excess: 250, + } + where classification = Industry.PLClass(exposure.industry), + base_rate = BaseRate(classification), + total_loads = LimitLoading(limit) + total_claims_loading + TurnoverDiscount(exposure.turnover) + } +} + +// --- Buildings, Contents and Stock (BC) — mandatory --- + +namespace BuildingsContentsStock { + // Buildings rate by fire class (from buildingsRates.csv, 25 classes A-Y) + BuildingsRate(classification: string): number { + match classification { + "B" => 0.15, + "Y" => 0.21, + _ => 0.1, + } + } + + // Material damage (contents fire) rate (from materialDamageRates.csv) + MaterialDamageRate(classification: string): number { + match classification { + "B" => 0.0658, + "Y" => 0.15, + _ => 0.07, + } + } + + // Contents theft rate (from contentsAndStockTheftRates.csv) + ContentsTheftRate(classification: string): number { + match classification { + "B" => 0.05625, + "F" => 0.06066, + _ => 0.06, + } + } + + // Stock theft rate (from contentsAndStockTheftRates.csv) + StockTheftRate(classification: string): number { + match classification { + "C" => 0.065, + "V" => 0.5, + _ => 0.08, + } + } + + // Theft area rate adjustment by risk score (from contentsAndStockTheftAreaRates.csv) + TheftAreaRate(theft_risk: number): number { + match theft_risk { + 1 => 0.25, 2 => 0.2, 3 => 0.15, 4 => 0.1, + 5 | 6 => 0, + 7 => -0.1, 8 => -0.15, 9 => -0.2, 10 => -0.25, + _ => 0, + } + } + + // Buildings size discount (from buildingsSizeDiscounts.csv) + BuildingsSizeDiscount(sum_insured: number): number { + match sum_insured { + 0 => 0, + [1..125000) => -0.025, + [125000..250000) => -0.05, + [250000..375000) => -0.0625, + [375000..500000) => -0.075, + [500000..625000) => -0.0875, + [625000..750000) => -0.1, + [750000..875000) => -0.125, + [875000..) => -0.15, + _ => 0, + } + } + + // Contents and stock size discount (from contentsAndStockSizeDiscounts.csv) + ContentsSizeDiscount(sum_insured: number): number { + match sum_insured { + 0 => 0, + [1..125000) => -0.025, + [125000..250000) => -0.05, + [250000..375000) => -0.0625, + [375000..500000) => -0.075, + [500000..625000) => -0.0875, + [625000..750000) => -0.1, + [750000..875000) => -0.125, + [875000..) => -0.15, + _ => 0, + } + } + + // Flood rate adjustment (from floodTable.csv, by risk score and banding) + FloodAdjustment(flood_risk: number, flood_banding: string): number { + match flood_risk { + 7 | 8 => 0.1, + _ => 0, + } + } + + // Listed building loading (from listedBuildingTable.csv) + ListedBuildingLoading(listed_type: string): number { + match listed_type { + "notListed" => 0, + "grade2Listed" | "gradeB2Listed" | "gradeCsListed" => 0.4, + _ => 0, + } + } + + // Facility type loading (from facilitiesTable.csv, summed for selected types) + FacilityLoading(has_outdoor_play: bool, has_functions: bool): number { + (if has_outdoor_play then 1 else 0) + + (if has_functions then 0.25 else 0) + } + + // Number of beds discount (from numberOfBedsTable.csv) + BedsDiscount(number_of_beds: string): number { + match number_of_beds { + "upTo5" => -0.05, + "upTo10" => 0, + "upTo20" => 1, + _ => 0, + } + } + + // Premises factor: combined loading from building characteristics + PremisesFactor( + listed_type: string, + flood_risk: number, + flood_banding: string, + has_outdoor_play: bool, + has_functions: bool, + number_of_beds: string, + ): number { + ListedBuildingLoading(listed_type) + + FloodAdjustment(flood_risk, flood_banding) + + FacilityLoading(has_outdoor_play, has_functions) + + BedsDiscount(number_of_beds) + } + + // Buildings section premium + BuildingsPremium( + sum_insured: number, + classification: string, + premises_factor: number, + total_claims_loading: number, + ): number { + (sum_insured / 100) * (rate * (1 + total_loads)) + where rate = BuildingsRate(classification), + total_loads = premises_factor + + BuildingsSizeDiscount(sum_insured) + + total_claims_loading + } + + // Contents section premium + ContentsPremium( + sum_insured: number, + fire_class: string, + theft_class: string, + theft_risk: number, + premises_factor: number, + total_claims_loading: number, + ): number { + (sum_insured / 100) * (rate * (1 + total_loads)) + where rate = MaterialDamageRate(classification: fire_class) + ContentsTheftRate(classification: theft_class), + total_loads = premises_factor + + ContentsSizeDiscount(sum_insured) + + TheftAreaRate(theft_risk) + + total_claims_loading + } + + // Stock section premium + StockPremium( + sum_insured: number, + fire_class: string, + stock_theft_class: string, + theft_risk: number, + premises_factor: number, + total_claims_loading: number, + ): number { + (sum_insured / 100) * (rate * (1 + total_loads)) + where rate = MaterialDamageRate(classification: fire_class) + StockTheftRate(classification: stock_theft_class), + total_loads = premises_factor + + ContentsSizeDiscount(sum_insured) + + TheftAreaRate(theft_risk) + + total_claims_loading + } + + Rate(exposure: Exposure, bc: BCConfig, risks: RiskScores, total_claims_loading: number): CoverOutcome { + rated { + key: "BC", + name: "Buildings, contents and stock", + base_premium: buildings_prem + contents_prem + stock_prem, + limit: bc.buildings_limit + bc.contents_limit + bc.stock_limit, + excess: 400, + } + where flood_banding = Industry.FloodBanding(exposure.industry), + fire_class = Industry.BuildingsFireClass(exposure.industry), + contents_fire = Industry.ContentsFireClass(exposure.industry), + theft_class = Industry.ContentsTheftClass(exposure.industry), + stock_theft_class = Industry.StockTheftClass(exposure.industry), + pf = BuildingsContentsStock.PremisesFactor( + listed_type: bc.listed_type, + flood_risk: risks.flood_risk, + flood_banding, + has_outdoor_play: bc.has_outdoor_play, + has_functions: bc.has_functions, + number_of_beds: bc.number_of_beds, + ), + buildings_prem = BuildingsPremium( + sum_insured: bc.buildings_limit, + classification: fire_class, + premises_factor: pf, + total_claims_loading, + ), + contents_prem = ContentsPremium( + sum_insured: bc.contents_limit, + fire_class: contents_fire, + theft_class, theft_risk: risks.theft_risk, + premises_factor: pf, + total_claims_loading, + ), + stock_prem = StockPremium( + sum_insured: bc.stock_limit, + fire_class: contents_fire, + stock_theft_class, theft_risk: risks.theft_risk, + premises_factor: pf, + total_claims_loading, + ) + } +} + +// --- Business Interruption (BI) — optional --- + +namespace BusinessInterruption { + // BI fire rate (from bi_fire_rates.csv) + BIFireRate(classification: string): number { + match classification { + "C" => 0.15, + "I" => 0.154, + "Y" => 0.5, + _ => 0.08, + } + } + + // Basis of cover discount (from basis_of_cover_rates.csv) + BasisOfCoverDiscount(basis_of_cover: string): number { + match basis_of_cover { + "Gross Profit" => 0, + "Gross Revenue" => -0.5, + "Increased Cost of Working" => 0.5, + _ => 0, + } + } + + // Indemnity period discount and months (from indemnity_period_rates.csv) + IndemnityDiscount(indemnity_months: number): number { + match indemnity_months { + 12 => 0, + 18 => -0.1, + 24 => -0.2, + 36 => -0.3, + _ => 0, + } + } + + // Sum insured discount (from sum_insured_discounts.csv) + SumInsuredDiscount(sum_insured: number): number { + match sum_insured { + [1..125000) => -0.025, + [125000..250000) => -0.05, + [250000..375000) => -0.0625, + [375000..500000) => -0.075, + [500000..625000) => -0.0875, + [625000..750000) => -0.1, + [750000..875000) => -0.125, + [875000..1000000) => -0.15, + [1000000..1125000) => -0.1675, + [1125000..1250000) => -0.175, + [1250000..1375000) => -0.1875, + [1375000..1500001) => -0.2, + _ => 0, + } + } + + // Loss of licence rate (from loss_of_license_rates.csv) + LossOfLicenceDiscount(lol_class: string): number { + match lol_class { + "Low" => 0.1, + "Medium" => 0.125, + "High" => 0.15, + _ => 0.15, + } + } + + Rate(exposure: Exposure, bi: BIConfig, total_claims_loading: number): CoverOutcome { + rated { + key: "BI", + name: "Business interruption", + base_premium: bi_premium + lol_premium, + limit: sum_insured, + excess: 0, + } + where basis_si = bi.basis_of_cover_limit * (bi.indemnity_months / 12), + sum_insured = basis_si + bi.rent_receivable_limit, + bi_rate = BIFireRate(classification: Industry.BIFireClass(exposure.industry)), + bi_loads = SumInsuredDiscount(sum_insured) + + IndemnityDiscount(indemnity_months: bi.indemnity_months) + + BasisOfCoverDiscount(basis_of_cover: bi.basis_of_cover) + + total_claims_loading, + bi_premium = (sum_insured / 100) * (bi_rate * (1 + bi_loads)), + lol_class = Industry.LossOfLicenceClass(exposure.industry), + lol_rate = 0.0125, + lol_loads = LossOfLicenceDiscount(lol_class) + total_claims_loading, + lol_premium = if bi.loss_of_licence_limit > 0 + then (bi.loss_of_licence_limit / 100) * (lol_rate * (1 + lol_loads)) + else 0 + } +} + +// --- Employers Liability (EL) --- + +namespace EmployersLiability { + // Base rate by EL classification (from base_rates.csv) + BaseRate(classification: string): number { + match classification { + "A" => 0.005524, + "B" => 0.008825, + "C" => 0.017, + _ => 0.006825, + } + } + + // Turnover size discount (from discounts.csv) + TurnoverDiscount(turnover: number): number { + match turnover { + [0..100000) => 0, + [100000..150000) => -0.025, + [150000..200000) => -0.05, + [200000..250000) => -0.1, + [250000..500000) => -0.125, + [500000..750000) => -0.15, + [750000..) => -0.2, + _ => 0, + } + } + + Rate(exposure: Exposure, total_claims_loading: number): CoverOutcome { + rated { + key: "EL", + name: "Employers liability", + base_premium: (exposure.turnover / 100) * (base_rate * (1 + total_loads)), + limit: 10000000, + excess: 0, + } + where classification = Industry.ELClass(exposure.industry), + base_rate = BaseRate(classification), + total_loads = TurnoverDiscount(exposure.turnover) + total_claims_loading + } +} + +// --- Portable Business Equipment (PBE) --- + +namespace PortableEquipment { + Rate(limit: number, total_claims_loading: number): CoverOutcome { + if limit == 0 + then not_available { reason: "PBE not selected" } + else rated { + key: "EPE", + name: "Portable business equipment", + base_premium: (limit / 100) * (2.5 * (1 + total_claims_loading)), + limit: limit, + excess: 400, + } + } +} + +// --- Terrorism (TER) — optional, excluded from minimum premium floor --- + +namespace Terrorism { + // Postcode zone from terrorism risk score (from postcode_zone.csv + postcode_rates.csv) + PostcodeRate(terrorism_risk: number): number { + match terrorism_risk { + 1 => 0.00033, + 2 => 0.00029, + 3 | 4 => 0.00006, + _ => 0, + } + } + + Rate(risks: RiskScores, bc: BCConfig, bi: BIConfig): CoverOutcome { + if risks.terrorism_risk == 0 + then not_available { reason: "Terrorism risk zone unavailable" } + else if md_si == 0 + then not_available { reason: "No material damage sum insured" } + else rated { + key: "TER", + name: "Terrorism", + base_premium: md_si * PostcodeRate(risks.terrorism_risk) * 1.15, + limit: md_si + bi_si, + excess: 0, + } + where md_si = bc.buildings_limit + bc.contents_limit + bc.stock_limit, + bi_si = bi.basis_of_cover_limit * (bi.indemnity_months / 12) + } +} + +// --- Product entry point --- +// Validates exposure, rates all covers, applies claims loading, enforces minimum premium + +Product( + exposure: Exposure, + claims: ClaimsHistory, + risks: RiskScores, + bc: BCConfig, + bi: BIConfig, + pl_limit: number, + pbe_limit: number, +): ProductOutcome { + // Exposure validation + if exposure.number_of_employees > 49 + then declined { reasons: ["Maximum 49 employees allowed"] } + else if exposure.turnover > 5000000 + then declined { reasons: ["Maximum turnover 5,000,000"] } + else if claims.number_of_claims > 5 + then declined { reasons: ["Too many claims in history"] } + else if bc.number_of_beds == "over20" + then declined { reasons: ["Maximum 20 beds allowed"] } + // Rate all covers, check for failures, assemble product + else if any not_available {} in covers + then referred { + reasons: collect not_available { reason } in covers => reason, + } + else offered { + covers, + subtotal, + minimum_premium: min_prem, + total_gross_premium: round(total, 2), + total_net_premium: round(total * (1 - 0.35), 2), + commission_rate: 0.35, + currency: "GBP", + } + where total_claims_loading = Claims.TotalLoading(exposure, claims), + covers = { + pl: PublicLiability.Rate(exposure, limit: pl_limit, total_claims_loading), + bc: BuildingsContentsStock.Rate(exposure, bc, risks, total_claims_loading), + bi: BusinessInterruption.Rate(exposure, bi, total_claims_loading), + el: EmployersLiability.Rate(exposure, total_claims_loading), + pbe: PortableEquipment.Rate(limit: pbe_limit, total_claims_loading), + ter: Terrorism.Rate(risks, bc, bi), + }, + base_sum = sum(collect rated { base_premium } in covers => base_premium), + ter_premium = match covers.ter { + rated { base_premium } => base_premium, + _ => 0, + }, + subtotal = base_sum - ter_premium, + min_prem = MinimumPremium(exposure, claims, bc), + total = max(min_prem, subtotal) + ter_premium +} + +// --- Multi-industry demonstration --- +// When a product covers multiple industries, find the worst-case configuration + +MultiIndustryDemo(industries: list(string)) { + { + worst_pl_class: Industry.WorstPLClass(industries), + worst_el_class: Industry.WorstELClass(industries), + max_deep_frying_rate: Industry.MaxDeepFryingRate(industries), + max_min_premium: Industry.MaxMinPremiumDefault(industries), + all_pl_classes: collect row in industry_config { + row.code in industries => row.pl_class, + }, + } +} +`; + +// Scenario: Café (DRI-945), 5 employees, £300k turnover, sole trader, 3 years exp, no claims +// Own premises: buildings £500k, contents £100k, stock £50k, not listed +// BI: Gross Profit £200k, 18 months indemnity, no loss of licence +// PBE: £5,000 | Terrorism risk zone 3 +export const HOSPITALITY_INPUT = { + exposure: { + industry: "DRI-945", + number_of_employees: 5, + turnover: 300000, + is_sole_trader: true, + years_experience: 3, + }, + claims: { + number_of_claims: 0, + total_claims_value: 0, + }, + risks: { + flood_risk: 4, + theft_risk: 6, + terrorism_risk: 3, + }, + bc: { + buildings_limit: 500000, + contents_limit: 100000, + stock_limit: 50000, + listed_type: "notListed", + has_outdoor_play: false, + has_functions: true, + number_of_beds: "none", + has_heavy_deep_frying: false, + }, + bi: { + basis_of_cover: "Gross Profit", + basis_of_cover_limit: 200000, + indemnity_months: 18, + rent_receivable_limit: 0, + loss_of_licence_limit: 0, + }, + pl_limit: 2000000, + pbe_limit: 5000, + // Table data loaded from CSV artifact (industry_config.csv) + _tables: { + industry_config: parseCSV(industryConfigCSV), + }, +}; diff --git a/playground/src/examples/industry_config.csv b/playground/src/examples/industry_config.csv new file mode 100644 index 0000000..4e8096a --- /dev/null +++ b/playground/src/examples/industry_config.csv @@ -0,0 +1,9 @@ +code,buildings_fire_class,contents_fire_class,contents_theft_class,stock_theft_class,bi_fire_class,pl_class,pl_severity,el_class,el_severity,flood_banding,loss_of_licence_class,deep_frying_rate,min_premium_claims_free,min_premium_default +DRI-945,B,B,B,C,C,A,1,A,1,A,High,0.2,400,500 +DRI-946,B,B,B,C,C,A,1,A,1,A,High,0.2,400,500 +DRI-1955,Y,Y,F,V,I,B,2,C,3,C,High,0.35,600,750 +DRI-1956,Y,Y,F,V,I,B,2,C,3,C,High,0.35,600,750 +DRI-1957,Y,Y,F,V,I,C,3,C,3,D,High,0.35,600,750 +DRI-1958,Y,Y,F,V,I,C,3,C,3,D,High,0.35,350,500 +DRI-2857,Y,Y,F,V,I,B,2,C,3,C,High,0.35,600,750 +DRI-2858,Y,Y,F,V,I,B,2,B,2,B,High,0.2,600,750 diff --git a/playground/src/examples/insurance.axiom.ts b/playground/src/examples/insurance.axiom.ts new file mode 100644 index 0000000..7fd22f7 --- /dev/null +++ b/playground/src/examples/insurance.axiom.ts @@ -0,0 +1,153 @@ +export const INSURANCE_EXAMPLE = `// Axiom v1 — Insurance Rating Example +// Try editing this code and see the output update live! + +type Endorsement = + required { code: string, title: string } + | waived { code: string, reason: string } + +type RuleResult = + ok { factor: number, notes: list(string), endorsements: list(Endorsement) } + | referred { message: string } + +type CoverOutcome = + not_selected {} + | rated { premium: number, loading: number, notes: list(string), endorsements: list(Endorsement) } + | referred { reasons: list(string) } + +type ProductOutcome = + offered { total: number, covers: dict(CoverOutcome) } + | referred { reasons: list(string) } + +BuildingsConstructionRule(quote: { construction: string }): RuleResult { + match quote.construction { + "brick" => ok { factor: 1.00, notes: [], endorsements: [] }, + "stone" => ok { + factor: 1.05, + notes: ["stone_loading"], + endorsements: [ + required { code: "END-ST01", title: "Structural survey within 5 years" }, + ], + }, + "timber" => referred { message: "timber_construction" }, + _ => referred { message: "unknown_construction" } + } +} + +BuildingsClaimsRule(quote: { claims_count: number }): RuleResult { + match { + quote.claims_count == 0 => ok { factor: 0.95, notes: ["claims_free"], endorsements: [] }, + quote.claims_count <= 2 => ok { factor: 1.00, notes: [], endorsements: [] }, + quote.claims_count == 3 => ok { + factor: 1.20, + notes: ["claims_3"], + endorsements: [ + required { code: "END-CL01", title: "Claims history disclosure" }, + ], + }, + _ => referred { message: "claims_too_high" } + } +} + +AggregateRules( + rules: list(RuleResult), + base_premium: number, +): CoverOutcome { + if any referred in rules + then referred { + reasons: collect referred { message: m } in rules => m, + } + else rated { + premium: base_premium * product collect in rules { + ok { factor } => factor, + }, + loading: product collect in rules { + ok { factor } => factor, + }, + notes: flatten(collect ok { notes: n } in rules => n), + endorsements: flatten(collect ok { endorsements: e } in rules => e), + } +} + +BuildingsCover( + quote: { + has_buildings: bool, + construction: string, + claims_count: number, + buildings_sum_insured: number, + }, + base_rate: number, +): CoverOutcome { + if not quote.has_buildings + then not_selected {} + else AggregateRules( + rules: [ + BuildingsConstructionRule(quote: quote), + BuildingsClaimsRule(quote: quote), + ], + base_premium: quote.buildings_sum_insured / 1000 * base_rate, + ) +} + +ContentsCover( + quote: { + has_contents: bool, + contents_sum_insured: number, + }, + base_rate: number, +): CoverOutcome { + if not quote.has_contents + then not_selected {} + else rated { + premium: quote.contents_sum_insured / 1000 * base_rate, + loading: 1.00, + notes: [], + endorsements: [], + } +} + +AggregateCovers(covers: dict(CoverOutcome)): ProductOutcome { + if any referred in covers + then referred { + reasons: flatten(collect referred { reasons: rs } in covers => rs), + } + else offered { + total: sum(collect rated { premium: p } in covers => p), + covers: covers, + } +} + +Product( + quote: { + has_buildings: bool, + construction: string, + claims_count: number, + buildings_sum_insured: number, + has_contents: bool, + contents_sum_insured: number, + }, + rates: { + buildings_rate: number, + contents_rate: number, + }, +): ProductOutcome { + AggregateCovers(covers: { + buildings: BuildingsCover(quote: quote, base_rate: rates.buildings_rate), + contents: ContentsCover(quote: quote, base_rate: rates.contents_rate), + }) +} +`; + +export const INSURANCE_INPUT = { + quote: { + has_buildings: true, + construction: "brick", + claims_count: 1, + buildings_sum_insured: 500000, + has_contents: true, + contents_sum_insured: 50000, + }, + rates: { + buildings_rate: 0.50, + contents_rate: 0.75, + }, +}; diff --git a/playground/src/examples/landlords.axiom.ts b/playground/src/examples/landlords.axiom.ts new file mode 100644 index 0000000..466cd5d --- /dev/null +++ b/playground/src/examples/landlords.axiom.ts @@ -0,0 +1,348 @@ +import propertyTypeConfigCSV from './property_type_config.csv?raw'; +import { parseCSV } from '../utils/csv'; + +export const LANDLORDS_EXAMPLE = `// Landlords Property Owners +// Demonstrates nested exposures — each property is rated independently +// then aggregated with a multi-property discount + +// --- Input types --- + +type Property = { + address: string, + property_type: string, + buildings_sum_insured: number, + contents_sum_insured: number, + annual_rent: number, + year_built: number, + listed_type: string, + number_of_units: number, + flood_risk: number, + subsidence_risk: number, +} + +type LandlordExposure = { + number_of_employees: number, + turnover: number, + is_portfolio: bool, + properties: list(Property), +} + +type ClaimsHistory = { + number_of_claims: number, + total_claims_value: number, +} + +// --- Outcome types --- + +type ProductOutcome = + offered { + property_details: list(dict), + property_subtotal: number, + discount_rate: number, + property_net: number, + el_premium: number, + legal_premium: number, + total_gross: number, + total_net: number, + commission_rate: number, + currency: string, + } + | declined { reasons: list(string) } + +// --- Property type configuration (from property_type_config.csv) --- +// Rates and factors vary by property type: residential, commercial, HMO, mixed-use + +table property_type_config: list({ + property_type: string, + buildings_rate: number, + contents_rate: number, + pol_base: number, + flood_factor: number, + subsidence_factor: number, +}) + +namespace PropertyConfig { + BuildingsRate(property_type: string): number { + match row in property_type_config { row.property_type == property_type => row.buildings_rate, _ => 0.04 } + } + ContentsRate(property_type: string): number { + match row in property_type_config { row.property_type == property_type => row.contents_rate, _ => 0.05 } + } + POLBase(property_type: string): number { + match row in property_type_config { row.property_type == property_type => row.pol_base, _ => 50 } + } + FloodFactor(property_type: string): number { + match row in property_type_config { row.property_type == property_type => row.flood_factor, _ => 0.15 } + } + SubsidenceFactor(property_type: string): number { + match row in property_type_config { row.property_type == property_type => row.subsidence_factor, _ => 0.10 } + } +} + +// --- Per-property rating --- +// Each property is rated independently — buildings, contents, rent, property owners liability. +// Loadings are applied based on property characteristics (age, listed status, flood/subsidence risk). + +namespace PropertyRating { + // Listed building loading + ListedLoading(listed_type: string): number { + match listed_type { + "not_listed" => 0, + "grade_2" => 0.35, + "grade_1" => 0.75, + _ => 0, + } + } + + // Building age loading — older properties attract higher rates + AgeLoading(year_built: number): number { + match year_built { + [0..1900] => 0.25, + [1901..1945) => 0.15, + [1945..1980) => 0.05, + [1980..9999] => 0, + _ => 0, + } + } + + // Flood risk loading — scaled by property type flood factor + FloodRiskLoading(flood_risk: number, property_type: string): number { + if flood_risk <= 3 then 0 + else if flood_risk <= 6 then PropertyConfig.FloodFactor(property_type) * 0.5 + else PropertyConfig.FloodFactor(property_type) + } + + // Subsidence risk loading + SubsidenceRiskLoading(subsidence_risk: number, property_type: string): number { + if subsidence_risk <= 3 then 0 + else if subsidence_risk <= 6 then PropertyConfig.SubsidenceFactor(property_type) * 0.5 + else PropertyConfig.SubsidenceFactor(property_type) + } + + // Combined property loadings + TotalLoadings(prop: Property): number { + ListedLoading(prop.listed_type) + + AgeLoading(prop.year_built) + + FloodRiskLoading(prop.flood_risk, prop.property_type) + + SubsidenceRiskLoading(prop.subsidence_risk, prop.property_type) + } + + // Buildings section premium + BuildingsPremium(prop: Property): number { + (prop.buildings_sum_insured / 1000) * PropertyConfig.BuildingsRate(prop.property_type) * (1 + TotalLoadings(prop)) + } + + // Contents section premium + ContentsPremium(prop: Property): number { + (prop.contents_sum_insured / 1000) * PropertyConfig.ContentsRate(prop.property_type) * (1 + TotalLoadings(prop)) + } + + // Rent receivable premium + RentPremium(prop: Property): number { + if prop.annual_rent == 0 then 0 + else (prop.annual_rent / 1000) * 0.025 * (1 + TotalLoadings(prop)) + } + + // Property owners liability — per property, scaled by number of units + POLPremium(prop: Property): number { + PropertyConfig.POLBase(prop.property_type) * (1 + units_loading) + where units_loading = if prop.number_of_units > 4 then 0.25 + else if prop.number_of_units > 2 then 0.10 + else 0 + } + + // Total premium for a single property + Total(prop: Property): number { + BuildingsPremium(prop) + ContentsPremium(prop) + RentPremium(prop) + POLPremium(prop) + } + + // Per-property breakdown with rounded values + Breakdown(prop: Property) { + { + address: prop.address, + buildings_premium: round(BuildingsPremium(prop), 2), + contents_premium: round(ContentsPremium(prop), 2), + rent_premium: round(RentPremium(prop), 2), + pol_premium: round(POLPremium(prop), 2), + property_total: round(Total(prop), 2), + } + } +} + +// --- Multi-property discount --- +// Portfolio customers (via broker arrangement) get enhanced discounts + +MultiPropertyDiscount(num_properties: number, is_portfolio: bool): number { + if is_portfolio then portfolio_discount + else standard_discount + where standard_discount = match num_properties { + 1 => 0, + 2 => 0.05, + 3 => 0.075, + [4..6] => 0.10, + [7..10] => 0.125, + [11..99] => 0.15, + _ => 0, + }, + portfolio_discount = match num_properties { + [1..3] => 0.10, + [4..6] => 0.15, + [7..10] => 0.175, + [11..99] => 0.20, + _ => 0, + } +} + +// --- Employers Liability (landlord-level, not per-property) --- + +namespace EmployersLiability { + BaseRate(number_of_employees: number): number { + match number_of_employees { + 0 => 0, + [1..5] => 0.008, + [6..10] => 0.007, + [11..25] => 0.0065, + [26..99] => 0.006, + _ => 0.008, + } + } + + Rate(turnover: number, number_of_employees: number): number { + if number_of_employees == 0 then 0 + else round((turnover / 100) * BaseRate(number_of_employees), 2) + } +} + +// --- Legal Expenses (flat rate by portfolio size) --- + +LegalExpensesPremium(num_properties: number): number { + match num_properties { + [1..3] => 95, + [4..6] => 150, + [7..10] => 225, + [11..99] => 350, + _ => 95, + } +} + +// --- Claims loading (applied to property subtotal) --- + +ClaimsLoading(claims: ClaimsHistory): number { + match claims.number_of_claims { + 0 => -0.10, + 1 => 0, + 2 => 0.15, + 3 => 0.35, + 4 => 0.60, + [5..99] => 1.00, + _ => 0, + } +} + +// --- Worst-case lookups across all properties --- +// Useful for underwriting rules that check the riskiest property + +WorstFloodRisk(properties: list(Property)): number { + max collect prop in properties => prop.flood_risk +} + +WorstSubsidenceRisk(properties: list(Property)): number { + max collect prop in properties => prop.subsidence_risk +} + +TotalBuildingsSI(properties: list(Property)): number { + sum collect prop in properties => prop.buildings_sum_insured +} + +// --- Product entry point --- +// Validates exposure, rates each property, aggregates with discount, +// adds landlord-level covers (EL, legal expenses) + +Product(exposure: LandlordExposure, claims: ClaimsHistory): ProductOutcome { + if len(exposure.properties) == 0 + then declined { reasons: ["At least one property is required"] } + else if len(exposure.properties) > 25 + then declined { reasons: ["Maximum 25 properties per policy"] } + else if claims.number_of_claims > 5 + then declined { reasons: ["Too many claims — manual review required"] } + else if TotalBuildingsSI(exposure.properties) > 10000000 + then declined { reasons: ["Total buildings sum insured exceeds 10M limit"] } + else offered { + property_details: collect prop in exposure.properties => PropertyRating.Breakdown(prop), + property_subtotal: round(prop_subtotal, 2), + discount_rate: disc_rate, + property_net: round(prop_net, 2), + el_premium: el_prem, + legal_premium: legal_prem, + total_gross: round(total, 2), + total_net: round(total * (1 - 0.30), 2), + commission_rate: 0.30, + currency: "GBP", + } + where claims_load = ClaimsLoading(claims), + raw_property_total = sum collect prop in exposure.properties => PropertyRating.Total(prop), + prop_subtotal = raw_property_total * (1 + claims_load), + disc_rate = MultiPropertyDiscount(len(exposure.properties), exposure.is_portfolio), + prop_net = prop_subtotal * (1 - disc_rate), + el_prem = EmployersLiability.Rate(exposure.turnover, exposure.number_of_employees), + legal_prem = LegalExpensesPremium(len(exposure.properties)), + total = prop_net + el_prem + legal_prem +} +`; + +// Scenario: Small landlord with 3 properties +// - Residential flat in London (1960s build, 1 unit) +// - Commercial unit in Manchester (2005 build, 1 unit) +// - HMO in Bristol (Victorian, 6 units, grade 2 listed, higher flood risk) +export const LANDLORDS_INPUT = { + exposure: { + number_of_employees: 2, + turnover: 50000, + is_portfolio: false, + properties: [ + { + address: "Flat 4, 23 Camden Road, London NW1", + property_type: "residential", + buildings_sum_insured: 250000, + contents_sum_insured: 15000, + annual_rent: 14400, + year_built: 1962, + listed_type: "not_listed", + number_of_units: 1, + flood_risk: 2, + subsidence_risk: 3, + }, + { + address: "Unit 7, Enterprise Park, Manchester M4", + property_type: "commercial", + buildings_sum_insured: 400000, + contents_sum_insured: 30000, + annual_rent: 28000, + year_built: 2005, + listed_type: "not_listed", + number_of_units: 1, + flood_risk: 4, + subsidence_risk: 2, + }, + { + address: "12 Clifton Gardens, Bristol BS8", + property_type: "hmo", + buildings_sum_insured: 350000, + contents_sum_insured: 20000, + annual_rent: 42000, + year_built: 1885, + listed_type: "grade_2", + number_of_units: 6, + flood_risk: 7, + subsidence_risk: 5, + }, + ], + }, + claims: { + number_of_claims: 0, + total_claims_value: 0, + }, + _tables: { + property_type_config: parseCSV(propertyTypeConfigCSV), + }, +}; diff --git a/playground/src/examples/money.axiom.ts b/playground/src/examples/money.axiom.ts new file mode 100644 index 0000000..69907bf --- /dev/null +++ b/playground/src/examples/money.axiom.ts @@ -0,0 +1,78 @@ +export const MONEY_EXAMPLE = `// Money Type Plugin Demo +// Demonstrates: money literals, type-safe arithmetic, currency enforcement + +// --- Premium calculation with money types --- + +BasePremium(risk_score: number): money(GBP) { + match risk_score { + [1..3] => £500, + [4..6] => £750, + [7..10] => £1250, + _ => £350, + } +} + +AdminFee: money(GBP) { + £35 +} + +// Insurance premium tax (12% of premium) +IPT(premium: money(GBP)): money(GBP) { + premium * 0.12 +} + +// Multi-property discount +PropertyDiscount(num_properties: number, subtotal: money(GBP)): money(GBP) { + subtotal * discount_rate + where discount_rate = match num_properties { + 1 => 0, + 2 => 0.05, + 3 => 0.10, + [4..99] => 0.15, + _ => 0, + } +} + +// Minimum premium floor +MinPremium: money(GBP) { + £250 +} + +// Full product calculation with money throughout +Product(risk_score: number, num_properties: number): money(GBP) { + round(total, 2) + where base = BasePremium(risk_score) * num_properties, + discount = PropertyDiscount(num_properties, base), + net = base - discount, + floor = max(net, MinPremium), + ipt = IPT(floor), + total = floor + ipt + AdminFee +} + +// ISO code form works too +EuroExample: money(EUR) { + EUR1000 * 1.15 +} + +// Comparison operators +IsAffordable(premium: money(GBP)): bool { + premium <= £2000 +} + +// Full breakdown +Breakdown(risk_score: number, num_properties: number) { + { + base_premium: BasePremium(risk_score) * num_properties, + discount: PropertyDiscount(num_properties, BasePremium(risk_score) * num_properties), + admin_fee: AdminFee, + ipt: IPT(Product(risk_score, num_properties) - AdminFee), + total: Product(risk_score, num_properties), + affordable: IsAffordable(Product(risk_score, num_properties)), + } +} +`; + +export const MONEY_INPUT = { + risk_score: 5, + num_properties: 3, +}; diff --git a/playground/src/examples/property_type_config.csv b/playground/src/examples/property_type_config.csv new file mode 100644 index 0000000..d722439 --- /dev/null +++ b/playground/src/examples/property_type_config.csv @@ -0,0 +1,5 @@ +property_type,buildings_rate,contents_rate,pol_base,flood_factor,subsidence_factor +residential,0.035,0.045,35,0.15,0.10 +commercial,0.055,0.060,75,0.20,0.15 +hmo,0.065,0.055,95,0.15,0.12 +mixed_use,0.060,0.058,85,0.18,0.13 diff --git a/playground/src/examples/tradespeople.axiom.ts b/playground/src/examples/tradespeople.axiom.ts new file mode 100644 index 0000000..720fb4d --- /dev/null +++ b/playground/src/examples/tradespeople.axiom.ts @@ -0,0 +1,367 @@ +export const TRADESPEOPLE_EXAMPLE = `// Covea Tradespeople Platinum V2 +// Translated from Abacus ProductSchemeBuilder + 6 CoverSchemeBuilders +// Subset: 2 industries (DRI-103, DRI-284), employees 1-3 + +type CoverOutcome = + rated { + key: string, + name: string, + base_premium: money(GBP), + limit: number, + excess: money(GBP), + } + | not_available { reason: string } + +type ProductOutcome = + offered { + covers: dict(CoverOutcome), + total_gross_premium: money(GBP), + total_net_premium: money(GBP), + currency: string, + } + | declined { reasons: list(string) } + | referred { reasons: dict(string) } + +// --- Product-level adjustment factors (from CSV lookup tables) --- + +namespace Adjustments { + // Postcode group relativity (from group_relativity.csv, 50 groups) + GroupRelativity(postcode_group: number): number { + match postcode_group { + 1 => 0.85, 5 => 0.885, 10 => 0.924, 15 => 0.962, + 20 => 0.992, 22 => 1, 25 => 1.012, 30 => 1.032, + 35 => 1.055, 40 => 1.08, 45 => 1.108, 50 => 1.15, + _ => 1, + } + } + + // Years of experience relativity (from years_experience_relativities.csv) + YearsExperienceRelativity(years_experience: number): number { + match years_experience { + [0..1] => 0.92, + 2 => 0.98, + 3 => 1.02, + 4 => 1.06, + 5 => 1.1, + 6 => 1.09, + 7 => 1.08, + 8 => 1.07, + 9 => 1.06, + 10 => 1.05, + 11 => 1.04, + 12 => 1.02, + 13 | 14 => 1.01, + [15..] => 1, + _ => 1, + } + } + + // Claims loading — applies when policyholder has prior claims + ClaimsLoading(number_of_claims: number, years_since_last_claim: number): number { + if number_of_claims == 0 + then 1 + else match years_since_last_claim { + [0..2) => 1.1, + [2..3) => 1.075, + [3..4) => 1.05, + [4..5) => 1.025, + _ => 1, + } + } + + // No-claims discount — applies when policyholder has zero claims + NoClaimsLoading(number_of_claims: number, years_experience: number): number { + if number_of_claims > 0 + then 1 + else match years_experience { + [0..1) => 1, + [1..2) => 0.95, + [2..3) => 0.9, + [3..4) => 0.85, + [4..5) => 0.8, + [5..] => 0.75, + _ => 1, + } + } + + // Combined adjustment factor: product of all relativities + Factor( + postcode_group: number, + years_experience: number, + number_of_claims: number, + years_since_last_claim: number, + ): number { + GroupRelativity(postcode_group) + * YearsExperienceRelativity(years_experience) + * ClaimsLoading(number_of_claims, years_since_last_claim) + * NoClaimsLoading(number_of_claims, years_experience) + } +} + +// --- Public Liability (PL) --- + +namespace PublicLiability { + // 3-dimensional lookup: industry x limit x employees (from premium.csv, ~5700 rows) + BasePremium(industry: string, limit: number, employees: number): money(GBP) { + match (industry, limit, employees) { + ("DRI-103", 1000000, 1) => £163, ("DRI-103", 1000000, 2) => £256, ("DRI-103", 1000000, 3) => £398, + ("DRI-103", 2000000, 1) => £200, ("DRI-103", 2000000, 2) => £313, ("DRI-103", 2000000, 3) => £485, + ("DRI-103", 5000000, 1) => £251, ("DRI-103", 5000000, 2) => £391, ("DRI-103", 5000000, 3) => £609, + ("DRI-284", 1000000, 1) => £275, ("DRI-284", 1000000, 2) => £428, ("DRI-284", 1000000, 3) => £666, + ("DRI-284", 2000000, 1) => £336, ("DRI-284", 2000000, 2) => £518, ("DRI-284", 2000000, 3) => £812, + ("DRI-284", 5000000, 1) => £479, ("DRI-284", 5000000, 2) => £910, ("DRI-284", 5000000, 3) => £1323, + _ => £0, + } + } + + Excess(industry: string): money(GBP) { + match industry { + "DRI-103" => £100, + "DRI-284" => £250, + _ => £100, + } + } + + Rate(industry: string, limit: number, employees: number): CoverOutcome { + if bp == £0 + then not_available { reason: "No PL rate for industry" } + else rated { + key: "PL", + name: "Public Liability", + base_premium: bp, + limit: limit, + excess: Excess(industry), + } + where bp = BasePremium(industry, limit, employees) + } +} + +// --- Employers Liability (EL) --- + +namespace EmployersLiability { + // Fixed limit 10M. Premium per industry. Sole traders: insurable workers = max(0, manual - 1) + BasePremium(industry: string): money(GBP) { + match industry { + "DRI-103" => £137, + "DRI-284" => £1023, + _ => £0, + } + } + + InsurableManualWorkers(manual_workers: number, business_type: string): number { + if business_type == "sole_trader" + then max(0, manual_workers - 1) + else manual_workers + } + + Rate(industry: string, manual_workers: number, business_type: string): CoverOutcome { + if bp == £0 + then not_available { reason: "No EL rate for industry" } + else rated { + key: "EL", + name: "Employers Liability", + base_premium: bp * InsurableManualWorkers(manual_workers, business_type), + limit: £10000000, + excess: £0, + } + where bp = BasePremium(industry) + } +} + +// --- Portable Tools and Equipment (PTE) --- + +namespace PortableTools { + // Simple limit-based premium, no industry dependency (from premium.csv, 5 rows) + BasePremium(limit: number): money(GBP) { + match limit { + 1000 => £59.70, + 2500 => £126.35, + 5000 => £192.92, + 7500 => £244.86, + 10000 => £296.80, + _ => £0, + } + } + + Rate(limit: number): CoverOutcome { + if bp == £0 + then not_available { reason: "Invalid PTE limit" } + else rated { + key: "PTE", + name: "Portable Tools and Equipment", + base_premium: bp, + limit: limit, + excess: £60, + } + where bp = BasePremium(limit) + } +} + +// --- Own Plant and Machinery (OPM) --- + +namespace OwnPlant { + // Only available for eligible industries. Premium by limit x manual workers. + BasePremium(limit: number, manual_workers: number): money(GBP) { + match (limit, manual_workers) { + (5000, 1) => £78.11, (5000, 2) => £103.75, (5000, 3) => £128.20, + (10000, 1) => £104.15, (10000, 2) => £138.33, (10000, 3) => £170.93, + (25000, 1) => £115.28, (25000, 2) => £153.43, (25000, 3) => £190.00, + _ => £0, + } + } + + Rate(industry: string, limit: number, manual_workers: number): CoverOutcome { + if industry not in ["DRI-284"] + then not_available { reason: "Industry not eligible for OPM" } + else if bp == £0 + then not_available { reason: "Invalid OPM limit/workers combination" } + else rated { + key: "OPM", + name: "Own Plant and Machinery", + base_premium: bp, + limit: limit, + excess: £250, + } + where bp = BasePremium(limit, manual_workers) + } +} + +// --- Hired In Plant and Machinery (HPM) --- + +namespace HiredPlant { + // Only available for eligible industries. Premium by limit x manual workers. + BasePremium(limit: number, manual_workers: number): money(GBP) { + match (limit, manual_workers) { + (10000, 1) => £111.30, (10000, 2) => £145.48, (10000, 3) => £179.67, + (25000, 1) => £123.23, (25000, 2) => £162.18, (25000, 3) => £199.55, + (50000, 1) => £154.23, (50000, 2) => £202.73, (50000, 3) => £249.63, + _ => £0, + } + } + + Rate(industry: string, limit: number, manual_workers: number): CoverOutcome { + if industry not in ["DRI-284"] + then not_available { reason: "Industry not eligible for HPM" } + else if bp == £0 + then not_available { reason: "Invalid HPM limit/workers combination" } + else rated { + key: "HPM", + name: "Hired In Plant and Machinery", + base_premium: bp, + limit: limit, + excess: £250, + } + where bp = BasePremium(limit, manual_workers) + } +} + +// --- Contract Works (CW) --- + +namespace ContractWorks { + // Industry band determines premium tier (from industry_bands.csv) + IndustryBand(industry: string): number { + match industry { + "DRI-284" => 4, + _ => 0, + } + } + + // Premium by limit x employees x industry band (from premium.csv) + BasePremium(limit: number, employees: number, band: number): money(GBP) { + match (limit, band, employees) { + (100000, 1, 1) => £120.05, (100000, 1, 2) => £155.82, (100000, 1, 3) => £186.03, + (100000, 2, 1) => £141.51, (100000, 2, 2) => £183.65, (100000, 2, 3) => £218.63, + (100000, 3, 1) => £148.67, (100000, 3, 2) => £193.18, (100000, 3, 3) => £229.75, + (100000, 4, 1) => £162.98, (100000, 4, 2) => £211.47, (100000, 4, 3) => £251.22, + (250000, 1, 1) => £133.56, (250000, 1, 2) => £173.31, (250000, 1, 3) => £206.70, + (250000, 2, 1) => £157.41, (250000, 2, 2) => £204.32, (250000, 2, 3) => £243.27, + (250000, 3, 1) => £165.36, (250000, 3, 2) => £214.65, (250000, 3, 3) => £255.20, + (250000, 4, 1) => £181.26, (250000, 4, 2) => £235.32, (250000, 4, 3) => £279.84, + _ => £0, + } + } + + Rate(industry: string, limit: number, employees: number): CoverOutcome { + if band == 0 + then not_available { reason: "Industry not eligible for Contract Works" } + else if bp == £0 + then not_available { reason: "Invalid CW limit/employees combination" } + else rated { + key: "CW", + name: "Contract Works", + base_premium: bp, + limit: limit, + excess: £250, + } + where band = IndustryBand(industry), + bp = BasePremium(limit, employees, band) + } +} + +// --- Product entry point --- +// Validates exposure constraints, rates all covers, applies shared adjustments + +Product( + industry: string, + number_of_employees: number, + manual_workers: number, + business_type: string, + turnover: number, + postcode_group: number, + years_experience: number, + number_of_claims: number, + years_since_last_claim: number, + pl_limit: number, + pte_limit: number, + opm_limit: number, + hpm_limit: number, + cw_limit: number, +): ProductOutcome { + if number_of_employees > 10 + then declined { reasons: ["Maximum 10 employees allowed"] } + else if turnover > 2000000 + then declined { reasons: ["Maximum turnover 2,000,000"] } + else if manual_workers > number_of_employees + then declined { reasons: ["Manual workers cannot exceed total employees"] } + else if number_of_claims > 1 + then declined { reasons: ["Maximum 1 claim in last 5 years"] } + else if any not_available {} in covers + then referred { + reasons: collect not_available { reason } in covers => reason, + } + else offered { + covers: covers, + total_gross_premium: round(base_sum * adj, 2), + total_net_premium: round(base_sum * adj * 0.65, 2), + currency: "GBP", + } + where covers = { + pl: PublicLiability.Rate(industry, limit: pl_limit, employees: number_of_employees), + el: EmployersLiability.Rate(industry, manual_workers, business_type), + pte: PortableTools.Rate(limit: pte_limit), + opm: OwnPlant.Rate(industry, limit: opm_limit, manual_workers), + hpm: HiredPlant.Rate(industry, limit: hpm_limit, manual_workers), + cw: ContractWorks.Rate(industry, limit: cw_limit, employees: number_of_employees), + }, + adj = Adjustments.Factor(postcode_group, years_experience, number_of_claims, years_since_last_claim), + base_sum = sum(collect rated { base_premium } in covers => base_premium) +} +`; + +// Scenario: Roofer (DRI-284), 2 employees, limited company, no claims, 5 years experience +export const TRADESPEOPLE_INPUT = { + industry: "DRI-284", + number_of_employees: 2, + manual_workers: 2, + business_type: "limited_company", + turnover: 500000, + postcode_group: 22, + years_experience: 5, + number_of_claims: 0, + years_since_last_claim: 0, + pl_limit: 2000000, + pte_limit: 5000, + opm_limit: 10000, + hpm_limit: 25000, + cw_limit: 100000, +}; diff --git a/playground/src/lang/ast.ts b/playground/src/lang/ast.ts new file mode 100644 index 0000000..e0ab7a7 --- /dev/null +++ b/playground/src/lang/ast.ts @@ -0,0 +1,314 @@ +import { Location } from './diagnostics'; + +// --- Top-level declarations --- + +export interface ProgramNode { + kind: 'Program'; + body: Declaration[]; +} + +export type Declaration = TypeDeclaration | ExpressionDeclaration | NamespaceDeclaration | SourceDeclaration | TableDeclaration; + +export interface TypeDeclaration { + kind: 'TypeDeclaration'; + name: string; + alternatives: VariantAlternative[]; + shape?: Record; // Record type (no variants, just fields) + location?: Location; +} + +export interface VariantAlternative { + tag: string; + shape: Record; +} + +export interface SourceDeclaration { + kind: 'SourceDeclaration'; + name: string; + params: Parameter[]; + returnType: TypeAnnotation; + location?: Location; +} + +export interface TableDeclaration { + kind: 'TableDeclaration'; + name: string; + elementType: TypeAnnotation; + location?: Location; +} + +export interface NamespaceDeclaration { + kind: 'NamespaceDeclaration'; + name: string; + symbols: SymbolDeclaration[]; + types: TypeDeclaration[]; + expressions: ExpressionDeclaration[]; + sources: SourceDeclaration[]; + location?: Location; +} + +export interface SymbolDeclaration { + name: string; + type: TypeAnnotation; + value: Expr; +} + +export interface ExpressionDeclaration { + kind: 'ExpressionDeclaration'; + name: string; + params: Parameter[]; + returnType?: TypeAnnotation; + body: Expr; + location?: Location; +} + +export interface Parameter { + name: string; + type: TypeAnnotation; +} + +// --- Type annotations --- + +export interface TypeAnnotation { + keyword: string; + args: Expr[]; + shape?: Record; +} + +// --- Expressions --- + +export type Expr = + | LiteralExpr + | PluginLiteralExpr + | IdentifierExpr + | MemberExpr + | IndexExpr + | InfixExpr + | UnaryExpr + | CoercionExpr + | IfExpr + | MatchExpr + | CallExpr + | ListLiteralExpr + | DictLiteralExpr + | VariantConstructionExpr + | AnyExpr + | AllExpr + | CollectExpr + | AggregateCollectExpr + | ParenExpr + | WhereExpr; + +export interface LiteralExpr { + kind: 'Literal'; + value: number | string | boolean; + raw: string; + location?: Location; +} + +export interface PluginLiteralExpr { + kind: 'PluginLiteral'; + tag: string; + value: unknown; + displayValue: string; + location?: Location; +} + +export interface IdentifierExpr { + kind: 'Identifier'; + name: string; + location?: Location; +} + +export interface MemberExpr { + kind: 'MemberExpression'; + object: Expr; + property: string; + location?: Location; +} + +export interface IndexExpr { + kind: 'IndexExpression'; + object: Expr; + index: Expr; + location?: Location; +} + +export interface InfixExpr { + kind: 'InfixExpression'; + left: Expr; + operator: string; + right: Expr; + location?: Location; +} + +export interface UnaryExpr { + kind: 'UnaryExpression'; + operator: string; + operand: Expr; + location?: Location; +} + +export interface CoercionExpr { + kind: 'CoercionExpression'; + expression: Expr; + targetType: TypeAnnotation; + location?: Location; +} + +export interface IfExpr { + kind: 'IfExpression'; + condition: Expr; + then: Expr; + elseIfs: { condition: Expr; then: Expr }[]; + else: Expr; + location?: Location; +} + +export interface MatchExpr { + kind: 'MatchExpression'; + subject?: Expr; + binding?: string; // match binding in iterable { ... } + iterable?: Expr; // the list to iterate over + arms: MatchArm[]; + location?: Location; +} + +export interface MatchArm { + pattern: Pattern; + expression: Expr; +} + +export interface CallExpr { + kind: 'CallExpression'; + callee: string; + args: Expr[]; + namedArgs: Record; + allArgs: Expr[]; // All arguments in original source order (for intrinsic calls) + spread?: boolean; // ... — fill remaining params from scope by matching name + location?: Location; +} + +export interface ListLiteralExpr { + kind: 'ListLiteral'; + elements: Expr[]; + location?: Location; +} + +export interface DictLiteralExpr { + kind: 'DictLiteral'; + entries: { key: string; value: Expr }[]; + location?: Location; +} + +export interface VariantConstructionExpr { + kind: 'VariantConstruction'; + typeName?: string; + tag: string; + entries: { key: string; value: Expr }[]; + location?: Location; +} + +export interface AnyExpr { + kind: 'AnyExpression'; + pattern: Pattern; + list: Expr; + location?: Location; +} + +export interface AllExpr { + kind: 'AllExpression'; + pattern: Pattern; + list: Expr; + location?: Location; +} + +export interface CollectExpr { + kind: 'CollectExpression'; + pattern?: Pattern; // standard form: collect pattern in list => body + list: Expr; + body?: Expr; + binding?: string; // binding form: collect row in list { arms } + arms?: { pattern: Pattern; body: Expr }[]; + location?: Location; +} + +export interface AggregateCollectExpr { + kind: 'AggregateCollectExpression'; + aggregator: string; + list: Expr; + arms: { pattern: Pattern; body: Expr }[]; + binding?: string; // binding form: agg collect row in list { arms } + location?: Location; +} + +export interface ParenExpr { + kind: 'ParenExpression'; + expression: Expr; + location?: Location; +} + +export interface WhereExpr { + kind: 'WhereExpression'; + body: Expr; + bindings: { name: string; value: Expr }[]; + location?: Location; +} + +// --- Patterns --- + +export type Pattern = + | WildcardPattern + | LiteralPattern + | ExpressionPattern + | VariantPattern + | RangePattern + | AlternativePattern + | TuplePattern; + +export interface WildcardPattern { + kind: 'WildcardPattern'; + location?: Location; +} + +export interface LiteralPattern { + kind: 'LiteralPattern'; + value: number | string | boolean; + raw: string; + location?: Location; +} + +export interface ExpressionPattern { + kind: 'ExpressionPattern'; + expression: Expr; + location?: Location; +} + +export interface VariantPattern { + kind: 'VariantPattern'; + typeName?: string; + tag: string; + bindings: Record; // field -> alias (null = wildcard binding) + location?: Location; +} + +export interface RangePattern { + kind: 'RangePattern'; + openLeft: boolean; // ( = exclusive + openRight: boolean; // ) = exclusive + left?: number; + right?: number; + location?: Location; +} + +export interface AlternativePattern { + kind: 'AlternativePattern'; + patterns: Pattern[]; + location?: Location; +} + +export interface TuplePattern { + kind: 'TuplePattern'; + elements: Pattern[]; + location?: Location; +} diff --git a/playground/src/lang/checker.ts b/playground/src/lang/checker.ts new file mode 100644 index 0000000..03a8efb --- /dev/null +++ b/playground/src/lang/checker.ts @@ -0,0 +1,1273 @@ +import { + ProgramNode, Declaration, ExpressionDeclaration, TypeDeclaration, + SourceDeclaration, TableDeclaration, Expr, Pattern, TypeAnnotation, MatchArm, +} from './ast'; +import { Diagnostic, Location, error, warning } from './diagnostics'; +import { + TypeSig, TYPE_NUMBER, TYPE_STRING, TYPE_BOOL, TYPE_MIXED, + typeList, typeDict, typeVariant, typeMoney, typeToString, isAssignable, +} from './types'; + +interface ExprDeclInfo { + decl: ExpressionDeclaration | SourceDeclaration; + paramTypes: Record; + returnType?: TypeSig; +} + +// Intrinsic function signatures: [paramTypes, returnType] +interface IntrinsicSig { + params: { name: string; type: TypeSig }[]; + variadic?: boolean; + returnType: TypeSig | 'from_arg'; +} + +const INTRINSICS: Record = { + round: { params: [{ name: 'value', type: TYPE_NUMBER }, { name: 'decimals', type: TYPE_NUMBER }], returnType: TYPE_NUMBER }, + len: { params: [{ name: 'list', type: typeList() }], returnType: TYPE_NUMBER }, + flatten: { params: [{ name: 'list', type: typeList() }], returnType: 'from_arg' }, + product: { params: [{ name: 'collection', type: TYPE_MIXED }], returnType: TYPE_NUMBER }, + sum: { params: [{ name: 'collection', type: TYPE_MIXED }], returnType: TYPE_NUMBER }, + sum_money: { params: [{ name: 'collection', type: TYPE_MIXED }], returnType: TYPE_NUMBER }, + max: { params: [{ name: 'list', type: typeList(TYPE_NUMBER) }], variadic: true, returnType: TYPE_NUMBER }, + min: { params: [{ name: 'list', type: typeList(TYPE_NUMBER) }], variadic: true, returnType: TYPE_NUMBER }, +}; + +export interface CheckResult { + diagnostics: Diagnostic[]; + exprTypes: Map; + declTypes: Map; +} + +export function check(ast: ProgramNode, plugins?: import('./plugin').AxiomPlugin[]): CheckResult { + const diagnostics: Diagnostic[] = []; + const exprTypes = new Map(); + const declTypes = new Map(); + + // Track current namespace for unqualified name resolution + let currentCheckerNamespace: string | undefined; + + // Collect type declarations (top-level and namespaced) + const namedTypes = new Map(); + function registerTypeDecl(td: TypeDeclaration, name: string) { + // Record type: type Foo = { field: Type, ... } + if (td.shape) { + const shape: Record = {}; + for (const [field, ann] of Object.entries(td.shape)) { + shape[field] = resolveTypeAnnotation(ann); + } + namedTypes.set(name, { name, params: [], shape }); + return; + } + // Variant type: type Foo = tag { ... } | tag { ... } + const variants: Record> = {}; + for (const alt of td.alternatives) { + const shape: Record = {}; + for (const [field, ann] of Object.entries(alt.shape)) { + shape[field] = resolveTypeAnnotation(ann); + } + variants[alt.tag] = shape; + } + namedTypes.set(name, typeVariant(name, variants)); + } + + for (const decl of ast.body) { + if (decl.kind === 'TypeDeclaration') { + registerTypeDecl(decl, decl.name); + } + if (decl.kind === 'NamespaceDeclaration') { + currentCheckerNamespace = decl.name; + for (const typeDecl of decl.types) { + registerTypeDecl(typeDecl, `${decl.name}.${typeDecl.name}`); + } + currentCheckerNamespace = undefined; + } + } + + // Collect table declarations — typed list values in scope + const tableTypes = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'TableDeclaration') { + const elemType = resolveTypeAnnotation(decl.elementType); + tableTypes.set(decl.name, typeList(elemType)); + } + } + + // Collect expression declarations (top-level and namespaced) + const exprDecls = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'ExpressionDeclaration') { + const paramTypes: Record = {}; + for (const p of decl.params) { + paramTypes[p.name] = resolveTypeAnnotation(p.type); + } + const returnType = decl.returnType ? resolveTypeAnnotation(decl.returnType) : undefined; + exprDecls.set(decl.name, { decl, paramTypes, returnType }); + } + if (decl.kind === 'SourceDeclaration') { + const paramTypes: Record = {}; + for (const p of decl.params) { + paramTypes[p.name] = resolveTypeAnnotation(p.type); + } + const returnType = resolveTypeAnnotation(decl.returnType); + exprDecls.set(decl.name, { decl, paramTypes, returnType }); + } + if (decl.kind === 'NamespaceDeclaration') { + currentCheckerNamespace = decl.name; + for (const exprDecl of decl.expressions) { + const qualName = `${decl.name}.${exprDecl.name}`; + const paramTypes: Record = {}; + for (const p of exprDecl.params) { + paramTypes[p.name] = resolveTypeAnnotation(p.type); + } + const returnType = exprDecl.returnType ? resolveTypeAnnotation(exprDecl.returnType) : undefined; + exprDecls.set(qualName, { decl: exprDecl, paramTypes, returnType }); + } + for (const srcDecl of decl.sources) { + const qualName = `${decl.name}.${srcDecl.name}`; + const paramTypes: Record = {}; + for (const p of srcDecl.params) { + paramTypes[p.name] = resolveTypeAnnotation(p.type); + } + const returnType = resolveTypeAnnotation(srcDecl.returnType); + exprDecls.set(qualName, { decl: srcDecl, paramTypes, returnType }); + } + currentCheckerNamespace = undefined; + } + } + + // Check for duplicate expression names + { + const seen = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'ExpressionDeclaration') { + if (seen.has(decl.name)) { + diagnostics.push(error('type.duplicate_expression', `Duplicate expression name '${decl.name}'`, decl.location)); + } + seen.set(decl.name, decl.location); + } + } + } + + // Check for duplicate type names + { + const seen = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'TypeDeclaration') { + if (seen.has(decl.name)) { + diagnostics.push(error('type.duplicate_type', `Duplicate type name '${decl.name}'`, decl.location)); + } + seen.set(decl.name, decl.location); + } + } + } + + // Register payload-less variant tags as constants + const variantTagTypes = new Map(); + for (const decl of ast.body) { + const typeDecls = decl.kind === 'TypeDeclaration' ? [decl] + : decl.kind === 'NamespaceDeclaration' ? decl.types : []; + for (const td of typeDecls) { + const typeSig = namedTypes.get(td.name) ?? namedTypes.get( + decl.kind === 'NamespaceDeclaration' ? `${decl.name}.${td.name}` : td.name + ); + for (const alt of td.alternatives) { + if (Object.keys(alt.shape).length === 0 && typeSig) { + variantTagTypes.set(alt.tag, typeSig); + } + } + } + } + + // Collect namespace constants + const namespaceConstants = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'NamespaceDeclaration') { + for (const sym of decl.symbols) { + namespaceConstants.set(`${decl.name}.${sym.name}`, resolveTypeAnnotation(sym.type)); + } + } + } + + // Type-check each expression declaration + for (const [name, info] of exprDecls) { + // Source declarations have no body to type-check — just register their return type + if (info.decl.kind === 'SourceDeclaration') { + if (info.returnType) declTypes.set(name, info.returnType); + continue; + } + + const scope = new Map(); + for (const [pname, ptype] of Object.entries(info.paramTypes)) { + scope.set(pname, ptype); + } + // Set namespace context for unqualified resolution + const dotIdx = name.lastIndexOf('.'); + currentCheckerNamespace = dotIdx >= 0 ? name.substring(0, dotIdx) : undefined; + + const expected = info.returnType ?? null; + const bodyType = inferType(info.decl.body, scope, expected); + if (bodyType) { + declTypes.set(name, info.returnType ?? bodyType); + if (info.returnType) { + if (!isAssignable(bodyType, info.returnType)) { + diagnostics.push(error( + 'type.return_type_mismatch', + `Expression '${name}' body has type ${typeToString(bodyType)}, expected ${typeToString(info.returnType)}`, + info.decl.location, + )); + } + } + } + } + currentCheckerNamespace = undefined; + + return { diagnostics, exprTypes, declTypes }; + + // --- Helpers --- + + function resolveTypeAnnotation(ann: TypeAnnotation): TypeSig { + if (ann.shape) { + const shape: Record = {}; + for (const [k, v] of Object.entries(ann.shape)) { + shape[k] = resolveTypeAnnotation(v); + } + if (ann.keyword === 'dict') return typeDict(shape); + return typeDict(shape); + } + + switch (ann.keyword) { + case 'number': return TYPE_NUMBER; + case 'string': return TYPE_STRING; + case 'bool': return TYPE_BOOL; + case 'list': { + if (ann.args.length > 0) { + const argType = ann.args[0]; + if (argType.kind === 'Identifier') { + const elemType = resolveTypeKeyword(argType.name) ?? resolveTypeName(argType.name); + if (elemType) return typeList(elemType); + diagnostics.push(error('type.unknown_type', `Unknown type '${argType.name}'`, argType.location)); + } + } + return typeList(); + } + case 'dict': { + if (ann.args.length > 0) { + const argType = ann.args[0]; + if (argType.kind === 'Identifier') { + const valueType = resolveTypeKeyword(argType.name) ?? resolveTypeName(argType.name); + if (valueType) return typeDict(undefined, valueType); + diagnostics.push(error('type.unknown_type', `Unknown type '${argType.name}'`, argType.location)); + } + } + return typeDict(); + } + case 'money': { + const currency = ann.args[0]; + if (currency && currency.kind === 'Identifier') { + return typeMoney(currency.name); + } + return typeMoney('?'); + } + default: { + const resolved = resolveTypeName(ann.keyword); + if (resolved) return resolved; + diagnostics.push(error('type.unknown_type', `Unknown type '${ann.keyword}'`)); + return { name: ann.keyword, params: [] }; + } + } + } + + function resolveTypeName(name: string): TypeSig | null { + const direct = namedTypes.get(name); + if (direct) return direct; + // Try qualifying with current namespace + if (currentCheckerNamespace) { + return namedTypes.get(`${currentCheckerNamespace}.${name}`) ?? null; + } + return null; + } + + function resolveTypeKeyword(name: string): TypeSig | null { + switch (name) { + case 'number': return TYPE_NUMBER; + case 'string': return TYPE_STRING; + case 'bool': return TYPE_BOOL; + case 'list': return typeList(); + case 'dict': return typeDict(); + default: return null; + } + } + + /** Resolve which variant type a tag belongs to, considering context. */ + function resolveVariantTag(tag: string, qualifiedTypeName: string | undefined, expected: TypeSig | null): TypeSig | null { + // 1. Expected type from context (return type annotation) + if (expected?.variants && expected.variants[tag]) return expected; + // 2. Qualified type name + if (qualifiedTypeName) { + const resolved = namedTypes.get(qualifiedTypeName); + if (resolved?.variants && resolved.variants[tag]) return resolved; + } + // 3. First named type containing this tag + for (const [, typeSig] of namedTypes) { + if (typeSig.variants && typeSig.variants[tag]) return typeSig; + } + return null; + } + + /** Extract the element/value type from a list or dict type. */ + function resolveCollectionElemType(collType: TypeSig | null): TypeSig | null { + if (!collType) return null; + if (collType.name === 'list' || collType.name === 'dict') { + // Prefer the full element type (e.g. inline record shape from table declarations) + if (collType.elementType) return collType.elementType; + if (collType.params.length > 0) { + const elemName = collType.params[0]; + return namedTypes.get(elemName) ?? resolveTypeKeyword(elemName) ?? { name: elemName, params: [] }; + } + } + return null; + } + + function inferType(expr: Expr, scope: Map, expected: TypeSig | null = null): TypeSig | null { + switch (expr.kind) { + case 'Literal': { + if (typeof expr.value === 'number') return setType(expr, TYPE_NUMBER); + if (typeof expr.value === 'string') return setType(expr, TYPE_STRING); + if (typeof expr.value === 'boolean') return setType(expr, TYPE_BOOL); + return null; + } + + case 'PluginLiteral': { + if (plugins) { + for (const plugin of plugins) { + const type = plugin.checker?.inferLiteralType?.(expr.tag, expr.value); + if (type) return setType(expr, type); + } + } + return setType(expr, TYPE_MIXED); + } + + case 'Identifier': { + const t = scope.get(expr.name) ?? namespaceConstants.get(expr.name) ?? variantTagTypes.get(expr.name) ?? tableTypes.get(expr.name); + if (t) return setType(expr, t); + // Try qualifying with current namespace + if (currentCheckerNamespace) { + const nsT = namespaceConstants.get(`${currentCheckerNamespace}.${expr.name}`); + if (nsT) return setType(expr, nsT); + } + // Auto-resolve parameterless expression declarations + const paramlessInfo = exprDecls.get(expr.name) + ?? (currentCheckerNamespace ? exprDecls.get(`${currentCheckerNamespace}.${expr.name}`) : undefined); + if (paramlessInfo && paramlessInfo.decl.params.length === 0) { + const retType = paramlessInfo.returnType ?? inferType(paramlessInfo.decl.body, scope); + if (retType) return setType(expr, retType); + } + diagnostics.push(error('type.unresolved_symbol', `Unknown symbol '${expr.name}'`, expr.location)); + return null; + } + + case 'MemberExpression': { + const objType = inferType(expr.object, scope); + if (!objType) return null; + if (objType.shape && objType.shape[expr.property]) { + return setType(expr, objType.shape[expr.property]); + } + if (objType.variants) { + // Check if field exists on ALL alternatives (common field access) + const allHaveField = Object.values(objType.variants).every( + shape => expr.property in shape + ); + if (allHaveField) { + // All alternatives have this field — check they all have the same type + const types = Object.values(objType.variants).map(shape => shape[expr.property]); + if (types.length > 0) return setType(expr, types[0]); + } + diagnostics.push(error( + 'type.member_access_on_variant', + `Cannot access '${expr.property}' on variant type '${objType.name}' — narrow with match first`, + expr.location, + )); + return null; + } + diagnostics.push(error( + 'type.unknown_property', + `Property '${expr.property}' does not exist on ${typeToString(objType)}`, + expr.location, + )); + return null; + } + + case 'IndexExpression': { + const objType = inferType(expr.object, scope); + const idxType = inferType(expr.index, scope); + if (!objType) return null; + if (objType.name === 'list') { + if (idxType && !isAssignable(idxType, TYPE_NUMBER)) { + diagnostics.push(error('type.index_type', `List index must be number, got ${typeToString(idxType)}`, expr.location)); + } + return setType(expr, objType.params.length > 0 ? { name: objType.params[0], params: [] } : TYPE_MIXED); + } + if (objType.shape && idxType && expr.index.kind === 'Literal' && typeof expr.index.value === 'string') { + const fieldType = objType.shape[expr.index.value]; + if (fieldType) return setType(expr, fieldType); + diagnostics.push(error('type.unknown_property', `Key '${expr.index.value}' does not exist on ${typeToString(objType)}`, expr.location)); + return null; + } + return setType(expr, TYPE_MIXED); + } + + case 'InfixExpression': { + const leftType = inferType(expr.left, scope); + const rightType = inferType(expr.right, scope); + if (!leftType || !rightType) return null; + + // Let plugins handle operator type checking first + if (plugins) { + for (const plugin of plugins) { + const result = plugin.checker?.checkBinaryOp?.(expr.operator, leftType, rightType); + if (result) { + if ('error' in result) { + diagnostics.push(error('type.plugin_operator', (result as { error: string }).error, expr.location)); + return null; + } + return setType(expr, result as TypeSig); + } + } + } + + if (['+', '-', '*', '/', '%', '**'].includes(expr.operator)) { + if (isAssignable(leftType, TYPE_NUMBER) && isAssignable(rightType, TYPE_NUMBER)) { + return setType(expr, TYPE_NUMBER); + } + diagnostics.push(error( + 'type.operator_mismatch', + `Operator '${expr.operator}' requires number operands, got ${typeToString(leftType)} and ${typeToString(rightType)}`, + expr.location, + )); + return null; + } + + if (['==', '!=', '<', '>', '<=', '>='].includes(expr.operator)) { + return setType(expr, TYPE_BOOL); + } + if (['&&', '||'].includes(expr.operator)) { + if (!isAssignable(leftType, TYPE_BOOL)) { + diagnostics.push(error('type.operator_mismatch', `Left operand of '${expr.operator}' must be bool, got ${typeToString(leftType)}`, expr.location)); + } + if (!isAssignable(rightType, TYPE_BOOL)) { + diagnostics.push(error('type.operator_mismatch', `Right operand of '${expr.operator}' must be bool, got ${typeToString(rightType)}`, expr.location)); + } + return setType(expr, TYPE_BOOL); + } + if (expr.operator === 'in' || expr.operator === 'not in') { + if (rightType.name !== 'list') { + diagnostics.push(error('type.operator_mismatch', `Right operand of '${expr.operator}' must be a list, got ${typeToString(rightType)}`, expr.location)); + } + return setType(expr, TYPE_BOOL); + } + return setType(expr, TYPE_MIXED); + } + + case 'UnaryExpression': { + const operandType = inferType(expr.operand, scope); + if (expr.operator === '-') { + if (operandType && !isAssignable(operandType, TYPE_NUMBER)) { + diagnostics.push(error('type.operator_mismatch', `Unary '-' requires number, got ${typeToString(operandType)}`, expr.location)); + } + return setType(expr, TYPE_NUMBER); + } + if (expr.operator === 'not' || expr.operator === '!') { + if (operandType && !isAssignable(operandType, TYPE_BOOL)) { + diagnostics.push(error('type.operator_mismatch', `'${expr.operator}' requires bool, got ${typeToString(operandType)}`, expr.location)); + } + return setType(expr, TYPE_BOOL); + } + return operandType ? setType(expr, operandType) : null; + } + + case 'CoercionExpression': { + const sourceType = inferType(expr.expression, scope); + const targetType = resolveTypeAnnotation(expr.targetType); + if (sourceType) { + checkCoercionValidity(sourceType, targetType, expr.location); + } + return setType(expr, targetType); + } + + case 'IfExpression': { + const condType = inferType(expr.condition, scope); + if (condType && !isAssignable(condType, TYPE_BOOL)) { + diagnostics.push(error('type.condition_not_bool', `Condition must be bool, got ${typeToString(condType)}`, expr.condition.location)); + } + const thenType = inferType(expr.then, scope, expected); + const elseIfTypes: (TypeSig | null)[] = []; + for (const ei of expr.elseIfs) { + const eiCondType = inferType(ei.condition, scope); + if (eiCondType && !isAssignable(eiCondType, TYPE_BOOL)) { + diagnostics.push(error('type.condition_not_bool', `Condition must be bool, got ${typeToString(eiCondType)}`, ei.condition.location)); + } + elseIfTypes.push(inferType(ei.then, scope, expected)); + } + const elseType = inferType(expr.else, scope, expected); + + // If we have an expected variant type and all branches match, use it + if (expected?.variants) { + const allBranches = [thenType, elseType, ...elseIfTypes]; + const allMatch = allBranches.every(bt => bt && isAssignable(bt, expected)); + if (allMatch) return setType(expr, expected); + } + + // Check branch type consistency (non-variant case) + if (thenType && elseType && !expected?.variants) { + if (!isAssignable(thenType, elseType) && !isAssignable(elseType, thenType)) { + diagnostics.push(error( + 'type.branch_mismatch', + `'then' branch has type ${typeToString(thenType)}, 'else' branch has type ${typeToString(elseType)}`, + expr.location, + )); + } + } + + return setType(expr, thenType ?? elseType ?? TYPE_MIXED); + } + + case 'MatchExpression': { + // match binding in iterable { ... } — iteration form + if (expr.binding && expr.iterable) { + const iterableType = inferType(expr.iterable, scope); + const elemType = resolveCollectionElemType(iterableType) ?? TYPE_MIXED; + // Type-check arms with binding in scope + const armTypes: (TypeSig | null)[] = []; + for (const arm of expr.arms) { + const armScope = new Map(scope); + armScope.set(expr.binding, elemType); + armTypes.push(inferType(arm.expression, armScope, expected)); + } + const firstType = armTypes.find(t => t != null) ?? null; + if (firstType) { + for (let i = 1; i < armTypes.length; i++) { + const at = armTypes[i]; + if (at && !isAssignable(at, firstType) && !isAssignable(firstType, at)) { + diagnostics.push(error( + 'type.branch_mismatch', + `Match arm ${i + 1} has type ${typeToString(at)}, expected ${typeToString(firstType)}`, + expr.arms[i].expression.location, + )); + break; + } + } + } + return firstType ? setType(expr, firstType) : null; + } + + let subjectType: TypeSig | null = null; + if (expr.subject) { + if (expr.subject.kind === 'ListLiteral') { + // Tuple match subject — infer element types without consistency check + for (const el of expr.subject.elements) { + inferType(el, scope); + } + subjectType = typeList(TYPE_MIXED); + setType(expr.subject, subjectType); + } else { + subjectType = inferType(expr.subject, scope); + } + } + + // Check exhaustiveness for variant subjects + if (subjectType?.variants && expr.arms.length > 0) { + checkMatchExhaustiveness(subjectType, expr.arms, expr.location); + } + + const armTypes: (TypeSig | null)[] = []; + for (const arm of expr.arms) { + const armScope = new Map(scope); + if (subjectType) { + checkPatternAgainstType(arm.pattern, subjectType, expr.location); + } + bindPatternVars(arm.pattern, subjectType, armScope); + armTypes.push(inferType(arm.expression, armScope, expected)); + } + + if (expected?.variants) { + return setType(expr, expected); + } + + // Build combined variant from all arms and check for tag conflicts + const combinedVariants: Record> = {}; + const tagSeenAtArm: Record = {}; + let hasVariants = false; + + for (let i = 0; i < armTypes.length; i++) { + const armType = armTypes[i]; + if (!armType?.variants) continue; + hasVariants = true; + for (const [tag, shape] of Object.entries(armType.variants)) { + if (tag in combinedVariants) { + // Same tag seen before — check fields are consistent + const existing = combinedVariants[tag]; + const existingFields = Object.keys(existing).sort().join(','); + const newFields = Object.keys(shape).sort().join(','); + if (existingFields !== newFields) { + const prevArm = tagSeenAtArm[tag] + 1; + diagnostics.push(error( + 'type.branch_mismatch', + `Match arm ${i + 1} constructs '${tag}' with fields {${Object.keys(shape).join(', ')}}, but arm ${prevArm} has {${Object.keys(existing).join(', ')}}`, + expr.arms[i].expression.location, + )); + } else { + // Same fields — check types are compatible + for (const [field, fieldType] of Object.entries(shape)) { + const existingFieldType = existing[field]; + if (existingFieldType && !isAssignable(fieldType, existingFieldType)) { + diagnostics.push(error( + 'type.branch_mismatch', + `Match arm ${i + 1}: field '${field}' of '${tag}' has type ${typeToString(fieldType)}, but arm ${tagSeenAtArm[tag] + 1} has ${typeToString(existingFieldType)}`, + expr.arms[i].expression.location, + )); + } + } + } + } else { + combinedVariants[tag] = shape; + tagSeenAtArm[tag] = i; + } + } + } + + if (hasVariants && Object.keys(combinedVariants).length > 0) { + // Use first arm's name (tag) since these are ad-hoc + const firstName = armTypes.find(t => t?.variants)!.name; + const combined: TypeSig = { name: firstName, params: [], variants: combinedVariants }; + return setType(expr, combined); + } + + // Non-variant: check branch consistency and return first type + const firstType = armTypes.find(t => t != null) ?? null; + if (firstType) { + for (let i = 0; i < armTypes.length; i++) { + const at = armTypes[i]; + if (at && at !== firstType && !isAssignable(at, firstType) && !isAssignable(firstType, at)) { + diagnostics.push(error( + 'type.branch_mismatch', + `Match arm ${i + 1} has type ${typeToString(at)}, expected ${typeToString(firstType)}`, + expr.arms[i].expression.location, + )); + break; + } + } + } + + return firstType ? setType(expr, firstType) : null; + } + + case 'CallExpression': { + // Named expression calls (try direct, then qualify with current namespace) + let resolvedCallee = expr.callee; + let info = exprDecls.get(resolvedCallee); + if (!info && currentCheckerNamespace) { + resolvedCallee = `${currentCheckerNamespace}.${expr.callee}`; + info = exprDecls.get(resolvedCallee); + } + if (info) { + checkExpressionCallArgs(expr, info, scope); + const ret = info.returnType ?? declTypes.get(resolvedCallee) ?? TYPE_MIXED; + return setType(expr, ret); + } + + // Let plugins override intrinsic type checking + if (plugins) { + const allArgExprs = expr.allArgs || [...expr.args, ...Object.values(expr.namedArgs)]; + const pluginArgTypes: TypeSig[] = []; + for (const arg of allArgExprs) { + const t = inferType(arg, scope); + if (t) pluginArgTypes.push(t); + } + for (const plugin of plugins) { + const result = plugin.checker?.checkCall?.(expr.callee, pluginArgTypes); + if (result) return setType(expr, result); + } + } + + // Intrinsic functions + const intrinsicSig = INTRINSICS[expr.callee]; + if (intrinsicSig) { + // Collect all arg types (positional + named, for shorthand support) + const allArgExprs = [...expr.args, ...Object.values(expr.namedArgs)]; + const argTypes: TypeSig[] = []; + for (const arg of allArgExprs) { + const t = inferType(arg, scope); + if (t) argTypes.push(t); + } + + const totalArgs = allArgExprs.length; + + // Arity check (skip for variadic intrinsics with 2+ args) + if (!intrinsicSig.variadic && totalArgs !== intrinsicSig.params.length) { + diagnostics.push(error( + 'type.argument_count', + `'${expr.callee}' expects ${intrinsicSig.params.length} argument(s), got ${totalArgs}`, + expr.location, + )); + } + // Arg type checks — for variadic with multiple args, check each is number + if (intrinsicSig.variadic && totalArgs > 1) { + for (let i = 0; i < argTypes.length; i++) { + if (!isAssignable(argTypes[i], TYPE_NUMBER)) { + diagnostics.push(error( + 'type.argument_mismatch', + `Argument ${i + 1} of '${expr.callee}' expects number, got ${typeToString(argTypes[i])}`, + allArgExprs[i].location, + )); + } + } + } else { + for (let i = 0; i < Math.min(argTypes.length, intrinsicSig.params.length); i++) { + const expected = intrinsicSig.params[i].type; + if (expected.name !== 'mixed' && !isAssignable(argTypes[i], expected)) { + diagnostics.push(error( + 'type.argument_mismatch', + `Argument '${intrinsicSig.params[i].name}' of '${expr.callee}' expects ${typeToString(expected)}, got ${typeToString(argTypes[i])}`, + allArgExprs[i].location, + )); + } + } + } + + if (intrinsicSig.returnType === 'from_arg') { + if (expr.callee === 'flatten' && argTypes.length > 0 && argTypes[0].name === 'list') { + // flatten(list(list(T))) -> list(T): unwrap one nesting level + const innerType = argTypes[0].elementType; + if (innerType && innerType.name === 'list') { + return setType(expr, innerType); + } + return setType(expr, typeList(TYPE_MIXED)); + } + return setType(expr, argTypes.length > 0 ? argTypes[0] : TYPE_MIXED); + } + return setType(expr, intrinsicSig.returnType); + } + + // Unknown function + for (const arg of expr.args) inferType(arg, scope); + for (const argExpr of Object.values(expr.namedArgs)) inferType(argExpr, scope); + diagnostics.push(error('type.unknown_function', `Unknown function '${expr.callee}'`, expr.location)); + return setType(expr, TYPE_MIXED); + } + + case 'ListLiteral': { + // If the expected type is list(T), propagate T as context for elements + let expectedElemType: TypeSig | null = null; + if (expected?.name === 'list' && expected.params.length > 0) { + expectedElemType = namedTypes.get(expected.params[0]) + ?? resolveTypeKeyword(expected.params[0]) + ?? null; + } + + const elemTypes: TypeSig[] = []; + for (const el of expr.elements) { + const t = inferType(el, scope, expectedElemType); + if (t) elemTypes.push(t); + } + + // Validate each element against the expected element type + if (expectedElemType) { + for (let i = 0; i < elemTypes.length; i++) { + if (!isAssignable(elemTypes[i], expectedElemType)) { + diagnostics.push(error( + 'type.list_element_mismatch', + `List element at index ${i} has type ${typeToString(elemTypes[i])}, expected ${typeToString(expectedElemType)}`, + expr.elements[i].location, + )); + } + } + } else if (elemTypes.length > 1) { + // No expected type — check consistency among elements + const first = elemTypes[0]; + for (let i = 1; i < elemTypes.length; i++) { + if (!isAssignable(elemTypes[i], first) && !isAssignable(first, elemTypes[i])) { + diagnostics.push(error( + 'type.mixed_list', + `List element at index ${i} has type ${typeToString(elemTypes[i])}, expected ${typeToString(first)}`, + expr.elements[i].location, + )); + break; + } + } + } + + const inferredElem = expectedElemType ?? elemTypes[0] ?? TYPE_MIXED; + return setType(expr, typeList(inferredElem)); + } + + case 'DictLiteral': { + // If expected is dict(T), propagate T as context for values + const expectedValueType = (expected?.name === 'dict' && expected.elementType) ? expected.elementType : null; + + const shape: Record = {}; + const valueTypes: TypeSig[] = []; + for (const entry of expr.entries) { + const t = inferType(entry.value, scope, expectedValueType); + if (t) { + shape[entry.key] = t; + valueTypes.push(t); + } + } + + // If we have an expected value type, validate and return typed dict + if (expectedValueType) { + for (let i = 0; i < valueTypes.length; i++) { + if (!isAssignable(valueTypes[i], expectedValueType)) { + diagnostics.push(error( + 'type.dict_value_mismatch', + `Dict value '${expr.entries[i].key}' has type ${typeToString(valueTypes[i])}, expected ${typeToString(expectedValueType)}`, + expr.entries[i].value.location, + )); + } + } + return setType(expr, typeDict(shape, expectedValueType)); + } + + // Infer value type from entries if all values have the same type + if (valueTypes.length > 0) { + const first = valueTypes[0]; + const allSame = valueTypes.every(t => isAssignable(t, first)); + if (allSame) return setType(expr, typeDict(shape, first)); + } + + return setType(expr, typeDict(shape)); + } + + case 'VariantConstruction': { + const tag = expr.tag; + const resolvedType = resolveVariantTag(tag, expr.typeName, expected); + + // Infer types for all provided entries regardless of resolution + const providedFields = new Map(); + for (const entry of expr.entries) { + const t = inferType(entry.value, scope); + if (t) providedFields.set(entry.key, t); + } + + // Validate fields when the author explicitly opted into a named type: + // either via an expected type from context (return annotation, list element type) + // or via a qualified name (RuleResult.ok { ... }) + const hasExplicitType = expected !== null || expr.typeName !== undefined; + + if (!resolvedType || !hasExplicitType) { + // No explicit type context — build a structural ad-hoc variant + const shape: Record = {}; + for (const [k, v] of providedFields) shape[k] = v; + // Ad-hoc variant — use tag name so it's treated as anonymous, not as the named type + return setType(expr, { name: tag, params: [], variants: { [tag]: shape } }); + } + + // Validate fields against the resolved named type + const declaredShape = resolvedType.variants![tag]; + + // Check for missing required fields + for (const field of Object.keys(declaredShape)) { + if (!providedFields.has(field)) { + diagnostics.push(error( + 'type.missing_field', + `Variant '${tag}' is missing required field '${field}' (expected ${typeToString(declaredShape[field])})`, + expr.location, + )); + } + } + + // Check for extra fields + for (const [field] of providedFields) { + if (!(field in declaredShape)) { + diagnostics.push(error( + 'type.extra_field', + `Variant '${tag}' has no field '${field}'`, + expr.location, + )); + } + } + + // Check field types + for (const [field, actualType] of providedFields) { + const expectedType = declaredShape[field]; + if (expectedType && !isAssignable(actualType, expectedType)) { + diagnostics.push(error( + 'type.field_type_mismatch', + `Field '${field}' of '${tag}' expects ${typeToString(expectedType)}, got ${typeToString(actualType)}`, + expr.location, + )); + } + } + + return setType(expr, resolvedType); + } + + case 'AnyExpression': + case 'AllExpression': { + const collType = inferType(expr.list, scope); + if (collType && collType.name !== 'list' && collType.name !== 'dict') { + diagnostics.push(error('type.not_iterable', `'${expr.kind === 'AnyExpression' ? 'any' : 'all'}' requires a list or dict, got ${typeToString(collType)}`, expr.location)); + } + const elemType = resolveCollectionElemType(collType); + if (elemType) checkPatternAgainstType(expr.pattern, elemType, expr.location); + return setType(expr, TYPE_BOOL); + } + + case 'CollectExpression': { + const collType = inferType(expr.list, scope); + if (collType && collType.name !== 'list' && collType.name !== 'dict') { + diagnostics.push(error('type.not_iterable', `'collect' requires a list or dict, got ${typeToString(collType)}`, expr.location)); + } + + // Binding form: collect row in list { arms } + if (expr.binding && expr.arms) { + const elemType = resolveCollectionElemType(collType) ?? TYPE_MIXED; + const armTypes: (TypeSig | null)[] = []; + for (const arm of expr.arms) { + const armScope = new Map(scope); + armScope.set(expr.binding, elemType); + armTypes.push(inferType(arm.body, armScope)); + } + const bodyType = armTypes.find(t => t != null) ?? null; + return setType(expr, typeList(bodyType ?? TYPE_MIXED)); + } + + // Standard form: collect pattern in list => body + const collectScope = new Map(scope); + const elemType = resolveCollectionElemType(collType); + if (expr.pattern && elemType) checkPatternAgainstType(expr.pattern, elemType, expr.location); + if (expr.pattern) bindPatternVars(expr.pattern, elemType, collectScope); + const bodyType = expr.body ? inferType(expr.body, collectScope) : null; + // Collect from dict → dict, from list → list + const isDict = collType && (collType.name === 'dict' || (collType.shape && !Array.isArray(collType.shape))); + if (isDict) { + return setType(expr, typeDict(undefined, bodyType ?? TYPE_MIXED)); + } + return setType(expr, typeList(bodyType ?? TYPE_MIXED)); + } + + case 'AggregateCollectExpression': { + const collType = inferType(expr.list, scope); + if (collType && collType.name !== 'list' && collType.name !== 'dict') { + diagnostics.push(error('type.not_iterable', `Aggregate collect requires a list or dict, got ${typeToString(collType)}`, expr.location)); + } + const elemType = resolveCollectionElemType(collType); + for (const arm of expr.arms) { + const armScope = new Map(scope); + if (expr.binding) { + armScope.set(expr.binding, elemType ?? TYPE_MIXED); + } else { + if (elemType) checkPatternAgainstType(arm.pattern, elemType, expr.location); + bindPatternVars(arm.pattern, elemType, armScope); + } + inferType(arm.body, armScope); + } + // Validate aggregator exists + if (!INTRINSICS[expr.aggregator]) { + diagnostics.push(error('type.unknown_function', `Unknown aggregator '${expr.aggregator}'`, expr.location)); + } + const intrinsic = INTRINSICS[expr.aggregator]; + if (intrinsic) { + return setType(expr, intrinsic.returnType === 'from_arg' ? TYPE_MIXED : intrinsic.returnType); + } + return setType(expr, TYPE_MIXED); + } + + case 'ParenExpression': { + const inner = inferType(expr.expression, scope, expected); + return inner ? setType(expr, inner) : null; + } + + case 'WhereExpression': { + const whereScope = new Map(scope); + for (const binding of expr.bindings) { + const t = inferType(binding.value, whereScope); + if (t) whereScope.set(binding.name, t); + } + const bodyType = inferType(expr.body, whereScope, expected); + return bodyType ? setType(expr, bodyType) : null; + } + } + + return null; + } + + function setType(expr: Expr, t: TypeSig): TypeSig { + exprTypes.set(expr, t); + return t; + } + + // --- Expression call argument validation --- + + function checkExpressionCallArgs( + expr: Expr & { kind: 'CallExpression'; callee: string; args: Expr[]; namedArgs: Record }, + info: ExprDeclInfo, + scope: Map, + ) { + const paramNames = info.decl.params.map(p => p.name); + const namedArgNames = new Set(Object.keys(expr.namedArgs)); + const hasSpread = !!(expr as any).spread; + const hasNamed = namedArgNames.size > 0; + const hasPositional = expr.args.length > 0; + + if (hasNamed || hasSpread) { + // Validate named args (includes shorthand) + for (const name of namedArgNames) { + if (!info.paramTypes[name]) { + const suggestion = findClosest(name, paramNames); + const msg = suggestion + ? `Unknown argument '${name}' — did you mean '${suggestion}'?` + : `Unknown argument '${name}' for '${expr.callee}'`; + diagnostics.push(error('type.unknown_argument', msg, expr.location)); + } + } + + // Check for missing arguments (account for both named, positional, and spread) + const positionallyBound = new Set(); + for (let i = 0; i < Math.min(expr.args.length, paramNames.length); i++) { + positionallyBound.add(paramNames[i]); + } + for (const pname of paramNames) { + if (namedArgNames.has(pname) || positionallyBound.has(pname)) continue; + // If spread is active, try to resolve from scope + if (hasSpread) { + const scopeType = scope.get(pname); + if (scopeType) { + // Type-check the spread-resolved variable + const expectedType = info.paramTypes[pname]; + if (expectedType && expectedType.name !== 'mixed' && !isAssignable(scopeType, expectedType)) { + diagnostics.push(error( + 'type.argument_mismatch', + `Spread argument '${pname}' has type ${typeToString(scopeType)}, expected ${typeToString(expectedType)}`, + expr.location, + )); + } + continue; + } + } + diagnostics.push(error( + 'type.missing_argument', + `Missing argument '${pname}' in call to '${expr.callee}' (expected ${typeToString(info.paramTypes[pname])})`, + expr.location, + )); + } + + // Type-check named args + for (const [name, argExpr] of Object.entries(expr.namedArgs)) { + const expectedType = info.paramTypes[name] ?? null; + const argType = inferType(argExpr, scope, expectedType); + if (argType && expectedType) { + checkArgType(expr.callee, name, argType, expectedType, argExpr.location); + } + } + + // Type-check any positional args alongside named + for (let i = 0; i < Math.min(expr.args.length, paramNames.length); i++) { + if (namedArgNames.has(paramNames[i])) continue; + const expectedType = info.paramTypes[paramNames[i]] ?? null; + const argType = inferType(expr.args[i], scope, expectedType); + if (argType && expectedType) { + checkArgType(expr.callee, paramNames[i], argType, expectedType, expr.args[i].location); + } + } + } else if (hasPositional) { + if (expr.args.length !== paramNames.length) { + const sig = paramNames.map(n => `${n}: ${typeToString(info.paramTypes[n])}`).join(', '); + diagnostics.push(error( + 'type.argument_count', + `'${expr.callee}' expects ${paramNames.length} argument(s) (${sig}), got ${expr.args.length}`, + expr.location, + )); + } + + for (let i = 0; i < Math.min(expr.args.length, paramNames.length); i++) { + const expectedType = info.paramTypes[paramNames[i]] ?? null; + const argType = inferType(expr.args[i], scope, expectedType); + if (argType && expectedType) { + checkArgType(expr.callee, paramNames[i], argType, expectedType, expr.args[i].location); + } + } + } else if (paramNames.length > 0) { + const sig = paramNames.map(n => `${n}: ${typeToString(info.paramTypes[n])}`).join(', '); + diagnostics.push(error( + 'type.argument_count', + `'${expr.callee}' expects ${paramNames.length} argument(s) (${sig}), got 0`, + expr.location, + )); + } + } + + function checkArgType(callee: string, paramName: string, actual: TypeSig, expected: TypeSig, loc?: Location) { + if (expected.name === 'mixed') return; + // For shaped dict params, check structural compatibility + if (expected.shape && actual.shape) { + for (const [field, fieldType] of Object.entries(expected.shape)) { + if (!actual.shape[field]) { + diagnostics.push(error( + 'type.argument_mismatch', + `Argument '${paramName}' of '${callee}' is missing field '${field}' (expected ${typeToString(fieldType)})`, + loc, + )); + } else if (!isAssignable(actual.shape[field], fieldType)) { + diagnostics.push(error( + 'type.argument_mismatch', + `Field '${field}' of argument '${paramName}' expects ${typeToString(fieldType)}, got ${typeToString(actual.shape[field])}`, + loc, + )); + } + } + return; + } + if (!isAssignable(actual, expected)) { + diagnostics.push(error( + 'type.argument_mismatch', + `Argument '${paramName}' of '${callee}' expects ${typeToString(expected)}, got ${typeToString(actual)}`, + loc, + )); + } + } + + // --- Variant constructor / pattern validation --- + + function checkPatternAgainstType(pattern: Pattern, subjectType: TypeSig, loc?: Location) { + if (pattern.kind === 'AlternativePattern') { + for (const alt of pattern.patterns) { + checkPatternAgainstType(alt, subjectType, loc); + } + return; + } + if (pattern.kind === 'VariantPattern' && subjectType.variants) { + if (!subjectType.variants[pattern.tag]) { + const validTags = Object.keys(subjectType.variants); + const suggestion = findClosest(pattern.tag, validTags); + const msg = suggestion + ? `Unknown variant tag '${pattern.tag}' on type '${subjectType.name}' — did you mean '${suggestion}'?` + : `Unknown variant tag '${pattern.tag}' on type '${subjectType.name}'. Valid tags: ${validTags.join(', ')}`; + diagnostics.push(error('type.unknown_tag', msg, pattern.location)); + } else { + // Check that bindings reference valid fields + const declaredShape = subjectType.variants[pattern.tag]; + for (const field of Object.keys(pattern.bindings)) { + if (!(field in declaredShape)) { + const validFields = Object.keys(declaredShape); + const suggestion = findClosest(field, validFields); + const msg = suggestion + ? `Variant '${pattern.tag}' has no field '${field}' — did you mean '${suggestion}'?` + : `Variant '${pattern.tag}' has no field '${field}'`; + diagnostics.push(error('type.unknown_field', msg, pattern.location)); + } + } + } + } + } + + function checkMatchExhaustiveness(subjectType: TypeSig, arms: MatchArm[], loc?: Location) { + if (!subjectType.variants) return; + const allTags = new Set(Object.keys(subjectType.variants)); + let hasWildcard = false; + + for (const arm of arms) { + const patterns = arm.pattern.kind === 'AlternativePattern' ? arm.pattern.patterns : [arm.pattern]; + for (const pat of patterns) { + if (pat.kind === 'WildcardPattern') { + hasWildcard = true; + } else if (pat.kind === 'VariantPattern') { + allTags.delete(pat.tag); + } + } + } + + if (!hasWildcard && allTags.size > 0) { + const missing = Array.from(allTags).join(', '); + diagnostics.push(error( + 'type.non_exhaustive_match', + `Match is not exhaustive — missing tags: ${missing}`, + loc, + )); + } + } + + // --- Coercion validation --- + + function checkCoercionValidity(source: TypeSig, target: TypeSig, loc?: Location) { + // Valid coercions: string → number, string → money, number → money, number → string + const validCoercions: [string, string][] = [ + ['string', 'number'], + ['string', 'money'], + ['number', 'money'], + ['number', 'string'], + ['money', 'number'], + ['money', 'string'], + ]; + if (isAssignable(source, target)) return; // already compatible, coercion is redundant but fine + const isValid = validCoercions.some(([from, to]) => source.name === from && target.name === to); + if (!isValid) { + diagnostics.push(error( + 'type.invalid_coercion', + `Cannot coerce ${typeToString(source)} to ${typeToString(target)}`, + loc, + )); + } + } + + // --- Pattern variable binding --- + + function bindPatternVars(pattern: Pattern, subjectType: TypeSig | null, scope: Map): void { + if (pattern.kind === 'TuplePattern') { + for (const elem of pattern.elements) { + bindPatternVars(elem, null, scope); + } + return; + } + if (pattern.kind === 'VariantPattern') { + let payloadShape: Record | null = null; + if (subjectType?.variants) { + payloadShape = subjectType.variants[pattern.tag] ?? null; + } + if (!payloadShape) { + for (const [, typeSig] of namedTypes) { + if (typeSig.variants && typeSig.variants[pattern.tag]) { + payloadShape = typeSig.variants[pattern.tag]; + break; + } + } + } + + for (const [field, alias] of Object.entries(pattern.bindings)) { + if (alias === null) continue; + const fieldType = payloadShape?.[field] ?? TYPE_MIXED; + scope.set(alias, fieldType); + } + } + } + + // --- Utility --- + + function findClosest(name: string, candidates: string[]): string | null { + let best: string | null = null; + let bestDist = Infinity; + for (const c of candidates) { + const d = editDistance(name.toLowerCase(), c.toLowerCase()); + if (d < bestDist && d <= 3) { + bestDist = d; + best = c; + } + } + return best; + } + + function editDistance(a: string, b: string): number { + const m = a.length, n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; + } +} diff --git a/playground/src/lang/diagnostics.ts b/playground/src/lang/diagnostics.ts new file mode 100644 index 0000000..2f2e2e4 --- /dev/null +++ b/playground/src/lang/diagnostics.ts @@ -0,0 +1,23 @@ +export interface Location { + line: number; + column: number; + offset: number; + length: number; +} + +export type Severity = 'error' | 'warning' | 'info'; + +export interface Diagnostic { + severity: Severity; + code: string; + message: string; + location?: Location; +} + +export function error(code: string, message: string, location?: Location): Diagnostic { + return { severity: 'error', code, message, location }; +} + +export function warning(code: string, message: string, location?: Location): Diagnostic { + return { severity: 'warning', code, message, location }; +} diff --git a/playground/src/lang/evaluator.ts b/playground/src/lang/evaluator.ts new file mode 100644 index 0000000..c5b9a35 --- /dev/null +++ b/playground/src/lang/evaluator.ts @@ -0,0 +1,702 @@ +import { + ProgramNode, ExpressionDeclaration, SourceDeclaration, TableDeclaration, Expr, Pattern, MatchArm, +} from './ast'; + +type Value = number | string | boolean | ValueArray | ValueRecord | null; +interface ValueArray extends Array {} +interface ValueRecord { [key: string]: Value } + +interface Scope { + vars: Map; + parent?: Scope; +} + +function scopeGet(scope: Scope, name: string): Value | undefined { + const val = scope.vars.get(name); + if (val !== undefined) return val; + if (scope.parent) return scopeGet(scope.parent, name); + return undefined; +} + +function childScope(parent: Scope): Scope { + return { vars: new Map(), parent }; +} + +/** Extract numeric values from a list or dict. */ +function numericValues(v: Value): number[] { + if (Array.isArray(v)) return v as number[]; + if (v && typeof v === 'object') return Object.values(v) as number[]; + return []; +} + +// Built-in intrinsic functions +const INTRINSICS: Record Value> = { + round: (n, decimals) => { + const d = typeof decimals === 'number' ? decimals : 0; + const factor = Math.pow(10, d); + return Math.round((n as number) * factor) / factor; + }, + len: (list) => { + if (Array.isArray(list)) return list.length; + if (list && typeof list === 'object') return Object.keys(list).length; + return 0; + }, + flatten: (list) => { + if (!Array.isArray(list)) return []; + return list.flat(); + }, + product: (list) => { + const vals = numericValues(list); + return vals.reduce((a, b) => a * b, 1); + }, + sum: (list) => { + const vals = numericValues(list); + return vals.reduce((a, b) => a + b, 0); + }, + sum_money: (list) => { + const vals = numericValues(list); + return vals.reduce((a, b) => a + b, 0); + }, + max: (...args) => { + if (args.length === 1 && Array.isArray(args[0])) { + return (args[0] as number[]).length === 0 ? 0 : Math.max(...(args[0] as number[])); + } + return Math.max(...(args as number[])); + }, + min: (...args) => { + if (args.length === 1 && Array.isArray(args[0])) { + return (args[0] as number[]).length === 0 ? 0 : Math.min(...(args[0] as number[])); + } + return Math.min(...(args as number[])); + }, +}; + +export interface EvalResult { + value: Value; + error?: string; +} + +export function evaluate( + ast: ProgramNode, + expressionName: string, + inputData: Record, + sourceData?: Record>, + tableData?: Record, + plugins?: import('./plugin').AxiomPlugin[], +): EvalResult { + try { + // Collect expression declarations (top-level and namespaced) + const exprDecls = new Map(); + const srcDecls = new Map(); + const tableDeclNames = new Set(); + for (const decl of ast.body) { + if (decl.kind === 'ExpressionDeclaration') { + exprDecls.set(decl.name, decl); + } + if (decl.kind === 'SourceDeclaration') { + srcDecls.set(decl.name, decl); + } + if (decl.kind === 'TableDeclaration') { + tableDeclNames.add(decl.name); + } + if (decl.kind === 'NamespaceDeclaration') { + for (const expr of decl.expressions) { + exprDecls.set(`${decl.name}.${expr.name}`, expr); + } + for (const src of decl.sources) { + srcDecls.set(`${decl.name}.${src.name}`, src); + } + } + } + + // Register payload-less variant tags as constants + const rootScope: Scope = { vars: new Map() }; + for (const decl of ast.body) { + const typeDecls = decl.kind === 'TypeDeclaration' ? [decl] + : decl.kind === 'NamespaceDeclaration' ? decl.types : []; + for (const td of typeDecls) { + for (const alt of td.alternatives) { + if (Object.keys(alt.shape).length === 0) { + rootScope.vars.set(alt.tag, { _tag: alt.tag }); + } + } + } + } + + // Collect namespace constants + const namespaceValues = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'NamespaceDeclaration') { + for (const sym of decl.symbols) { + const val = evalExpr(sym.value, rootScope); + namespaceValues.set(`${decl.name}.${sym.name}`, val); + rootScope.vars.set(`${decl.name}.${sym.name}`, val); + } + } + } + + // Register table data into root scope + if (tableData) { + for (const name of tableDeclNames) { + const data = tableData[name]; + if (data) { + rootScope.vars.set(name, data as unknown as Value); + } + } + } + + // Track current namespace for unqualified sibling resolution + let currentNamespace: string | undefined; + + const targetDecl = exprDecls.get(expressionName); + if (!targetDecl) { + return { value: null, error: `Expression '${expressionName}' not found` }; + } + + // Build scope from input data + const scope: Scope = { vars: new Map(rootScope.vars), parent: undefined }; + for (const param of targetDecl.params) { + const val = inputData[param.name]; + if (val !== undefined) { + scope.vars.set(param.name, val); + } else { + return { value: null, error: `Missing input parameter '${param.name}'` }; + } + } + + const value = evalExpr(targetDecl.body, scope); + return { value }; + + function evalExpr(expr: Expr, scope: Scope): Value { + switch (expr.kind) { + case 'Literal': + return expr.value; + + case 'PluginLiteral': + return expr.value as Value; + + case 'Identifier': { + const val = scopeGet(scope, expr.name); + if (val !== undefined) return val; + // Check namespace constants + const nsVal = namespaceValues.get(expr.name); + if (nsVal !== undefined) return nsVal; + // Try qualifying with current namespace + if (currentNamespace) { + const qualVal = namespaceValues.get(`${currentNamespace}.${expr.name}`); + if (qualVal !== undefined) return qualVal; + } + // Auto-evaluate parameterless expressions referenced by name + const paramlessDecl = exprDecls.get(expr.name) + ?? (currentNamespace ? exprDecls.get(`${currentNamespace}.${expr.name}`) : undefined); + if (paramlessDecl && paramlessDecl.params.length === 0) { + const prevNs = currentNamespace; + const dotIdx = expr.name.lastIndexOf('.'); + currentNamespace = dotIdx >= 0 ? expr.name.substring(0, dotIdx) : currentNamespace; + const result = evalExpr(paramlessDecl.body, childScope(scope)); + currentNamespace = prevNs; + return result; + } + throw new Error(`Undefined variable '${expr.name}'`); + } + + case 'MemberExpression': { + const obj = evalExpr(expr.object, scope); + if (obj && typeof obj === 'object' && !Array.isArray(obj)) { + return (obj as Record)[expr.property] ?? null; + } + throw new Error(`Cannot access property '${expr.property}' on ${typeof obj}`); + } + + case 'IndexExpression': { + const obj = evalExpr(expr.object, scope); + const idx = evalExpr(expr.index, scope); + if (Array.isArray(obj) && typeof idx === 'number') { + return obj[idx] ?? null; + } + if (obj && typeof obj === 'object' && typeof idx === 'string') { + return (obj as Record)[idx] ?? null; + } + return null; + } + + case 'InfixExpression': { + // Short-circuit for && and || + if (expr.operator === '&&') { + const left = evalExpr(expr.left, scope); + if (!left) return false; + return !!evalExpr(expr.right, scope); + } + if (expr.operator === '||') { + const left = evalExpr(expr.left, scope); + if (left) return true; + return !!evalExpr(expr.right, scope); + } + + const left = evalExpr(expr.left, scope); + const right = evalExpr(expr.right, scope); + + // Let plugins handle operator evaluation first + if (plugins) { + for (const plugin of plugins) { + if (plugin.evaluator?.supportsOp?.(left, right, expr.operator)) { + return plugin.evaluator.evaluateOp!(left, right, expr.operator) as Value; + } + } + } + + switch (expr.operator) { + case '+': return (left as number) + (right as number); + case '-': return (left as number) - (right as number); + case '*': return (left as number) * (right as number); + case '/': return (right as number) === 0 ? 0 : (left as number) / (right as number); + case '%': return (left as number) % (right as number); + case '**': return Math.pow(left as number, right as number); + case '==': return left === right; + case '!=': return left !== right; + case '<': return (left as number) < (right as number); + case '>': return (left as number) > (right as number); + case '<=': return (left as number) <= (right as number); + case '>=': return (left as number) >= (right as number); + case 'in': { + if (Array.isArray(right)) return right.includes(left); + return false; + } + case 'not in': { + if (Array.isArray(right)) return !right.includes(left); + return true; + } + default: + throw new Error(`Unknown operator '${expr.operator}'`); + } + } + + case 'UnaryExpression': { + const operand = evalExpr(expr.operand, scope); + switch (expr.operator) { + case '-': return -(operand as number); + case 'not': + case '!': return !operand; + default: return operand; + } + } + + case 'CoercionExpression': { + const val = evalExpr(expr.expression, scope); + const target = expr.targetType.keyword; + if (target === 'number' || target === 'money') { + if (typeof val === 'string') { + // Strip currency suffixes, percentage signs + const cleaned = val.replace(/[^0-9.\-]/g, ''); + return parseFloat(cleaned) || 0; + } + return typeof val === 'number' ? val : 0; + } + if (target === 'string') { + return String(val); + } + return val; + } + + case 'IfExpression': { + const cond = evalExpr(expr.condition, scope); + if (cond) return evalExpr(expr.then, scope); + for (const ei of expr.elseIfs) { + const eiCond = evalExpr(ei.condition, scope); + if (eiCond) return evalExpr(ei.then, scope); + } + return evalExpr(expr.else, scope); + } + + case 'MatchExpression': { + // match binding in iterable { ... } — iteration form + if (expr.binding && expr.iterable) { + const list = evalExpr(expr.iterable, scope); + if (!Array.isArray(list)) throw new Error('match-in requires a list'); + let fallbackArm: MatchArm | undefined; + for (const arm of expr.arms) { + if (arm.pattern.kind === 'WildcardPattern') { + fallbackArm = arm; + continue; + } + } + // Iterate over list, try non-wildcard arms for each element + for (const elem of list) { + const elemScope = childScope(scope); + elemScope.vars.set(expr.binding, elem); + for (const arm of expr.arms) { + if (arm.pattern.kind === 'WildcardPattern') continue; + const bindings = matchPattern(arm.pattern, null, elemScope); + if (bindings !== null) { + for (const [k, v] of Object.entries(bindings)) { + elemScope.vars.set(k, v); + } + return evalExpr(arm.expression, elemScope); + } + } + } + // No element matched — use wildcard fallback + if (fallbackArm) { + return evalExpr(fallbackArm.expression, scope); + } + throw new Error('No matching element in match-in expression'); + } + + // Standard match + const subject = expr.subject ? evalExpr(expr.subject, scope) : null; + for (const arm of expr.arms) { + const bindings = matchPattern(arm.pattern, subject, scope); + if (bindings !== null) { + const armScope = childScope(scope); + for (const [k, v] of Object.entries(bindings)) { + armScope.vars.set(k, v); + } + return evalExpr(arm.expression, armScope); + } + } + throw new Error('No matching arm in match expression'); + } + + case 'CallExpression': { + // Resolve callee: try direct, then qualify with current namespace + let qualifiedCallee = expr.callee; + let decl = exprDecls.get(qualifiedCallee); + if (!decl && currentNamespace) { + qualifiedCallee = `${currentNamespace}.${expr.callee}`; + decl = exprDecls.get(qualifiedCallee); + } + if (decl) { + const callScope = childScope(scope); + // Bind positional args + const positionallyBound = new Set(); + for (let i = 0; i < expr.args.length && i < decl.params.length; i++) { + callScope.vars.set(decl.params[i].name, evalExpr(expr.args[i], scope)); + positionallyBound.add(decl.params[i].name); + } + // Bind named args + const namedArgNames = new Set(Object.keys(expr.namedArgs)); + for (const [name, argExpr] of Object.entries(expr.namedArgs)) { + callScope.vars.set(name, evalExpr(argExpr, scope)); + } + // Spread: fill remaining params from caller scope by matching name + if (expr.spread) { + for (const param of decl.params) { + if (!positionallyBound.has(param.name) && !namedArgNames.has(param.name)) { + const val = scopeGet(scope, param.name); + if (val !== undefined) { + callScope.vars.set(param.name, val); + } + } + } + } + // Copy namespace constants + for (const [k, v] of namespaceValues) { + callScope.vars.set(k, v); + } + // Set namespace context from the resolved callee + const prevNamespace = currentNamespace; + const dotIdx = qualifiedCallee.lastIndexOf('.'); + currentNamespace = dotIdx >= 0 ? qualifiedCallee.substring(0, dotIdx) : undefined; + const result = evalExpr(decl.body, callScope); + currentNamespace = prevNamespace; + return result; + } + + // Source declarations — lookup in provided source data + { + let srcCallee = expr.callee; + let srcDecl = srcDecls.get(srcCallee); + if (!srcDecl && currentNamespace) { + srcCallee = `${currentNamespace}.${expr.callee}`; + srcDecl = srcDecls.get(srcCallee); + } + if (srcDecl && sourceData) { + const data = sourceData[srcCallee]; + if (!data) throw new Error(`No source data provided for '${srcCallee}'`); + // Build lookup key from evaluated args + const keyParts: string[] = []; + // Bind positional args + for (let i = 0; i < expr.args.length && i < srcDecl.params.length; i++) { + keyParts.push(String(evalExpr(expr.args[i], scope))); + } + // Bind named args + const positionalCount = Math.min(expr.args.length, srcDecl.params.length); + for (const param of srcDecl.params.slice(positionalCount)) { + const namedVal = expr.namedArgs[param.name]; + if (namedVal) { + keyParts.push(String(evalExpr(namedVal, scope))); + } else if (expr.spread) { + const val = scopeGet(scope, param.name); + if (val !== undefined) keyParts.push(String(val)); + } + } + const key = keyParts.join('|'); + const result = data[key]; + if (result === undefined) throw new Error(`Source '${srcCallee}' has no entry for key '${key}'`); + return result; + } + } + + // Plugin intrinsics (checked before built-ins, can override) + if (plugins) { + for (const plugin of plugins) { + const pluginFn = plugin.evaluator?.intrinsics?.[expr.callee]; + if (pluginFn) { + const args = (expr.allArgs || expr.args).map(a => evalExpr(a, scope)); + const result = pluginFn(...args); + if (result !== undefined) return result as Value; + } + } + } + + // Intrinsic functions + const intrinsic = INTRINSICS[expr.callee]; + if (intrinsic) { + // Use allArgs to preserve original source order + const args = (expr.allArgs || expr.args).map(a => evalExpr(a, scope)); + return intrinsic(...args); + } + + throw new Error(`Unknown function '${expr.callee}'`); + } + + case 'ListLiteral': + return expr.elements.map(e => evalExpr(e, scope)); + + case 'DictLiteral': { + const result: Record = {}; + for (const entry of expr.entries) { + result[entry.key] = evalExpr(entry.value, scope); + } + return result; + } + + case 'VariantConstruction': { + const result: Record = { _tag: expr.tag }; + for (const entry of expr.entries) { + result[entry.key] = evalExpr(entry.value, scope); + } + return result; + } + + case 'AnyExpression': { + const coll = evalExpr(expr.list, scope); + const items = iterableValues(coll); + return items.some(item => matchPattern(expr.pattern, item, scope) !== null); + } + + case 'AllExpression': { + const coll = evalExpr(expr.list, scope); + const items = iterableValues(coll); + return items.every(item => matchPattern(expr.pattern, item, scope) !== null); + } + + case 'CollectExpression': { + const coll = evalExpr(expr.list, scope); + + // Binding form: collect row in list { condition => body, ... } + if (expr.binding && expr.arms) { + const items = iterableValues(coll); + const result: Value[] = []; + const wildcardArm = expr.arms.find(a => a.pattern.kind === 'WildcardPattern'); + for (const item of items) { + const elemScope = childScope(scope); + elemScope.vars.set(expr.binding, item); + let matched = false; + for (const arm of expr.arms) { + if (arm.pattern.kind === 'WildcardPattern') continue; + const bindings = matchPattern(arm.pattern, null, elemScope); + if (bindings !== null) { + for (const [k, v] of Object.entries(bindings)) { + elemScope.vars.set(k, v); + } + result.push(evalExpr(arm.body, elemScope)); + matched = true; + break; + } + } + // Wildcard fallback: include element with default value + if (!matched && wildcardArm) { + result.push(evalExpr(wildcardArm.body, elemScope)); + } + } + return result; + } + + // Standard form: collect pattern in list => body + const isDict = coll && typeof coll === 'object' && !Array.isArray(coll); + + if (isDict) { + // Collect from dict → dict, preserving keys + const result: Record = {}; + for (const [key, item] of Object.entries(coll as Record)) { + const bindings = matchPattern(expr.pattern!, item, scope); + if (bindings !== null) { + const itemScope = childScope(scope); + for (const [k, v] of Object.entries(bindings)) { + itemScope.vars.set(k, v); + } + result[key] = evalExpr(expr.body!, itemScope); + } + } + return result; + } + + // Collect from list → list + const items = iterableValues(coll); + const result: Value[] = []; + for (const item of items) { + const bindings = matchPattern(expr.pattern!, item, scope); + if (bindings !== null) { + const itemScope = childScope(scope); + for (const [k, v] of Object.entries(bindings)) { + itemScope.vars.set(k, v); + } + result.push(evalExpr(expr.body!, itemScope)); + } + } + return result; + } + + case 'AggregateCollectExpression': { + const coll = evalExpr(expr.list, scope); + const items = iterableValues(coll); + const collected: Value[] = []; + + if (expr.binding) { + // Binding form: agg collect row in list { condition => body, ... } + const wildcardArm = expr.arms.find(a => a.pattern.kind === 'WildcardPattern'); + for (const item of items) { + const elemScope = childScope(scope); + elemScope.vars.set(expr.binding, item); + let matched = false; + for (const arm of expr.arms) { + if (arm.pattern.kind === 'WildcardPattern') continue; + const bindings = matchPattern(arm.pattern, null, elemScope); + if (bindings !== null) { + for (const [k, v] of Object.entries(bindings)) { + elemScope.vars.set(k, v); + } + collected.push(evalExpr(arm.body, elemScope)); + matched = true; + break; + } + } + if (!matched && wildcardArm) { + collected.push(evalExpr(wildcardArm.body, elemScope)); + } + } + } else { + // Standard form: agg collect in list { pattern => body, ... } + for (const item of items) { + for (const arm of expr.arms) { + const bindings = matchPattern(arm.pattern, item, scope); + if (bindings !== null) { + const armScope = childScope(scope); + for (const [k, v] of Object.entries(bindings)) { + armScope.vars.set(k, v); + } + collected.push(evalExpr(arm.body, armScope)); + break; + } + } + } + } + + // Apply aggregator + const agg = INTRINSICS[expr.aggregator]; + if (agg) return agg(collected); + throw new Error(`Unknown aggregator '${expr.aggregator}'`); + } + + case 'ParenExpression': + return evalExpr(expr.expression, scope); + + case 'WhereExpression': { + const whereScope = childScope(scope); + for (const binding of expr.bindings) { + whereScope.vars.set(binding.name, evalExpr(binding.value, whereScope)); + } + return evalExpr(expr.body, whereScope); + } + } + } + + /** Extract iterable values from a list (array) or dict (object values). */ + function iterableValues(val: Value): Value[] { + if (Array.isArray(val)) return val; + if (val && typeof val === 'object') return Object.values(val); + return []; + } + + function matchPattern( + pattern: Pattern, + value: Value, + scope: Scope, + ): Record | null { + switch (pattern.kind) { + case 'WildcardPattern': + return {}; + + case 'LiteralPattern': + return value === pattern.value ? {} : null; + + case 'VariantPattern': { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const obj = value as Record; + if (obj._tag !== pattern.tag) return null; + const bindings: Record = {}; + for (const [field, alias] of Object.entries(pattern.bindings)) { + if (!(field in obj)) return null; + if (alias !== null) { + bindings[alias] = obj[field]; + } + } + return bindings; + } + + case 'ExpressionPattern': { + // Evaluate the expression pattern as a condition + // For subject-less match, the expression IS the condition + if (value === null) { + const condValue = evalExpr(pattern.expression, scope); + return condValue ? {} : null; + } + const patternValue = evalExpr(pattern.expression, scope); + return value === patternValue ? {} : null; + } + + case 'RangePattern': { + if (typeof value !== 'number') return null; + const leftOk = pattern.left === undefined || (pattern.openLeft ? value > pattern.left : value >= pattern.left); + const rightOk = pattern.right === undefined || (pattern.openRight ? value < pattern.right : value <= pattern.right); + return leftOk && rightOk ? {} : null; + } + + case 'AlternativePattern': { + for (const alt of pattern.patterns) { + const result = matchPattern(alt, value, scope); + if (result !== null) return result; + } + return null; + } + + case 'TuplePattern': { + if (!Array.isArray(value)) return null; + if (value.length !== pattern.elements.length) return null; + const bindings: Record = {}; + for (let i = 0; i < pattern.elements.length; i++) { + const result = matchPattern(pattern.elements[i], value[i], scope); + if (result === null) return null; + Object.assign(bindings, result); + } + return bindings; + } + } + + return null; + } + } catch (e) { + return { value: null, error: e instanceof Error ? e.message : String(e) }; + } +} diff --git a/playground/src/lang/lexer.ts b/playground/src/lang/lexer.ts new file mode 100644 index 0000000..00365ae --- /dev/null +++ b/playground/src/lang/lexer.ts @@ -0,0 +1,271 @@ +import { Diagnostic, Location, error } from './diagnostics'; + +export enum TokenType { + // Literals + Number = 'Number', + String = 'String', + Bool = 'Bool', + + // Identifiers & keywords + Identifier = 'Identifier', + Type = 'Type', + Namespace = 'Namespace', + If = 'If', + Then = 'Then', + Else = 'Else', + Match = 'Match', + Not = 'Not', + In = 'In', + As = 'As', + Any = 'Any', + All = 'All', + Collect = 'Collect', + Where = 'Where', + Source = 'Source', + Table = 'Table', + True = 'True', + False = 'False', + + // Operators + Plus = 'Plus', + Minus = 'Minus', + Star = 'Star', + Slash = 'Slash', + Percent = 'Percent', + StarStar = 'StarStar', + Eq = 'Eq', + NotEq = 'NotEq', + Lt = 'Lt', + Gt = 'Gt', + LtEq = 'LtEq', + GtEq = 'GtEq', + And = 'And', + Or = 'Or', + Bang = 'Bang', + Arrow = 'Arrow', // => + Assign = 'Assign', // = + Pipe = 'Pipe', // | + + // Punctuation + LParen = 'LParen', + RParen = 'RParen', + LBracket = 'LBracket', + RBracket = 'RBracket', + LBrace = 'LBrace', + RBrace = 'RBrace', + Comma = 'Comma', + Colon = 'Colon', + Dot = 'Dot', + DotDot = 'DotDot', // .. + Spread = 'Spread', // ... + Underscore = 'Underscore', + + // Plugin + PluginLiteral = 'PluginLiteral', + + // Special + EOF = 'EOF', +} + +export interface Token { + type: TokenType; + value: string; + tag?: string; // For PluginLiteral: plugin-defined tag (e.g., 'money') + payload?: unknown; // For PluginLiteral: structured data for AST/evaluator + location: Location; +} + +const KEYWORDS: Record = { + type: TokenType.Type, + namespace: TokenType.Namespace, + if: TokenType.If, + then: TokenType.Then, + else: TokenType.Else, + match: TokenType.Match, + not: TokenType.Not, + in: TokenType.In, + as: TokenType.As, + any: TokenType.Any, + all: TokenType.All, + collect: TokenType.Collect, + where: TokenType.Where, + source: TokenType.Source, + table: TokenType.Table, + true: TokenType.True, + false: TokenType.False, +}; + +export function tokenize(source: string, plugins?: import('./plugin').AxiomPlugin[]): { tokens: Token[]; diagnostics: Diagnostic[] } { + const tokens: Token[] = []; + const diagnostics: Diagnostic[] = []; + let pos = 0; + let line = 1; + let col = 1; + + function loc(start: number, length: number): Location { + // Compute line/col for start position + let l = 1, c = 1; + for (let i = 0; i < start; i++) { + if (source[i] === '\n') { l++; c = 1; } else { c++; } + } + return { line: l, column: c, offset: start, length }; + } + + function peek(offset = 0): string { + return source[pos + offset] ?? '\0'; + } + + function advance(): string { + const ch = source[pos++]; + if (ch === '\n') { line++; col = 1; } else { col++; } + return ch; + } + + function match(expected: string): boolean { + if (source[pos] === expected) { advance(); return true; } + return false; + } + + while (pos < source.length) { + // Skip whitespace + if (/\s/.test(peek())) { advance(); continue; } + + // Skip comments + if (peek() === '/' && peek(1) === '/') { + while (pos < source.length && peek() !== '\n') advance(); + continue; + } + + const start = pos; + + // Try plugin tokenizers first + if (plugins) { + let handled = false; + for (const plugin of plugins) { + const result = plugin.lexer?.tryTokenize(source, pos); + if (result) { + tokens.push({ + type: TokenType.PluginLiteral, + value: result.value, + tag: result.tag, + payload: result.payload, + location: loc(start, result.length), + }); + for (let i = 0; i < result.length; i++) advance(); + handled = true; + break; + } + } + if (handled) continue; + } + + // Numbers + if (/[0-9]/.test(peek())) { + while (/[0-9]/.test(peek())) advance(); + if (peek() === '.' && peek(1) !== '.') { + advance(); + while (/[0-9]/.test(peek())) advance(); + } + tokens.push({ type: TokenType.Number, value: source.slice(start, pos), location: loc(start, pos - start) }); + continue; + } + + // Strings + if (peek() === '"') { + advance(); + let value = ''; + while (pos < source.length && peek() !== '"') { + if (peek() === '\\') { advance(); value += advance(); } + else { value += advance(); } + } + if (peek() === '"') advance(); + else diagnostics.push(error('parse.unterminated_string', 'Unterminated string', loc(start, pos - start))); + tokens.push({ type: TokenType.String, value, location: loc(start, pos - start) }); + continue; + } + + // Identifiers / keywords / underscore + if (/[a-zA-Z_]/.test(peek())) { + while (/[a-zA-Z0-9_]/.test(peek())) advance(); + const word = source.slice(start, pos); + + if (word === '_') { + tokens.push({ type: TokenType.Underscore, value: word, location: loc(start, pos - start) }); + } else if (word === 'true' || word === 'false') { + tokens.push({ type: TokenType.Bool, value: word, location: loc(start, pos - start) }); + } else if (KEYWORDS[word]) { + tokens.push({ type: KEYWORDS[word], value: word, location: loc(start, pos - start) }); + } else { + tokens.push({ type: TokenType.Identifier, value: word, location: loc(start, pos - start) }); + } + continue; + } + + // Multi-char operators + const ch = peek(); + switch (ch) { + case '=': + advance(); + if (match('=')) { tokens.push({ type: TokenType.Eq, value: '==', location: loc(start, 2) }); } + else if (match('>')) { tokens.push({ type: TokenType.Arrow, value: '=>', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Assign, value: '=', location: loc(start, 1) }); } + continue; + case '!': + advance(); + if (match('=')) { tokens.push({ type: TokenType.NotEq, value: '!=', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Bang, value: '!', location: loc(start, 1) }); } + continue; + case '<': + advance(); + if (match('=')) { tokens.push({ type: TokenType.LtEq, value: '<=', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Lt, value: '<', location: loc(start, 1) }); } + continue; + case '>': + advance(); + if (match('=')) { tokens.push({ type: TokenType.GtEq, value: '>=', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Gt, value: '>', location: loc(start, 1) }); } + continue; + case '&': + advance(); + if (match('&')) { tokens.push({ type: TokenType.And, value: '&&', location: loc(start, 2) }); } + else { diagnostics.push(error('parse.unexpected_char', `Unexpected character '&'`, loc(start, 1))); } + continue; + case '|': + advance(); + if (match('|')) { tokens.push({ type: TokenType.Or, value: '||', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Pipe, value: '|', location: loc(start, 1) }); } + continue; + case '*': + advance(); + if (match('*')) { tokens.push({ type: TokenType.StarStar, value: '**', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Star, value: '*', location: loc(start, 1) }); } + continue; + case '.': + advance(); + if (match('.')) { + if (match('.')) { tokens.push({ type: TokenType.Spread, value: '...', location: loc(start, 3) }); } + else { tokens.push({ type: TokenType.DotDot, value: '..', location: loc(start, 2) }); } + } + else { tokens.push({ type: TokenType.Dot, value: '.', location: loc(start, 1) }); } + continue; + case '+': advance(); tokens.push({ type: TokenType.Plus, value: '+', location: loc(start, 1) }); continue; + case '-': advance(); tokens.push({ type: TokenType.Minus, value: '-', location: loc(start, 1) }); continue; + case '/': advance(); tokens.push({ type: TokenType.Slash, value: '/', location: loc(start, 1) }); continue; + case '%': advance(); tokens.push({ type: TokenType.Percent, value: '%', location: loc(start, 1) }); continue; + case '(': advance(); tokens.push({ type: TokenType.LParen, value: '(', location: loc(start, 1) }); continue; + case ')': advance(); tokens.push({ type: TokenType.RParen, value: ')', location: loc(start, 1) }); continue; + case '[': advance(); tokens.push({ type: TokenType.LBracket, value: '[', location: loc(start, 1) }); continue; + case ']': advance(); tokens.push({ type: TokenType.RBracket, value: ']', location: loc(start, 1) }); continue; + case '{': advance(); tokens.push({ type: TokenType.LBrace, value: '{', location: loc(start, 1) }); continue; + case '}': advance(); tokens.push({ type: TokenType.RBrace, value: '}', location: loc(start, 1) }); continue; + case ',': advance(); tokens.push({ type: TokenType.Comma, value: ',', location: loc(start, 1) }); continue; + case ':': advance(); tokens.push({ type: TokenType.Colon, value: ':', location: loc(start, 1) }); continue; + default: + diagnostics.push(error('parse.unexpected_char', `Unexpected character '${ch}'`, loc(start, 1))); + advance(); + } + } + + tokens.push({ type: TokenType.EOF, value: '', location: loc(pos, 0) }); + return { tokens, diagnostics }; +} diff --git a/playground/src/lang/parser.ts b/playground/src/lang/parser.ts new file mode 100644 index 0000000..aecc2e1 --- /dev/null +++ b/playground/src/lang/parser.ts @@ -0,0 +1,1009 @@ +import { Token, TokenType } from './lexer'; +import { Diagnostic, Location, error } from './diagnostics'; +import { + ProgramNode, Declaration, TypeDeclaration, ExpressionDeclaration, + NamespaceDeclaration, SourceDeclaration, TableDeclaration, SymbolDeclaration, + VariantAlternative, Parameter, TypeAnnotation, Expr, MatchArm, Pattern, MatchExpr, +} from './ast'; + +export function parse(tokens: Token[]): { ast: ProgramNode; diagnostics: Diagnostic[] } { + const diagnostics: Diagnostic[] = []; + let pos = 0; + + function current(): Token { return tokens[pos] ?? tokens[tokens.length - 1]; } + function peek(offset = 0): Token { return tokens[pos + offset] ?? tokens[tokens.length - 1]; } + function at(type: TokenType): boolean { return current().type === type; } + function atValue(value: string): boolean { return current().value === value; } + + function advance(): Token { + const tok = current(); + if (pos < tokens.length - 1) pos++; + return tok; + } + + function expect(type: TokenType, msg?: string): Token { + if (at(type)) return advance(); + const tok = current(); + diagnostics.push(error('parse.expected', msg ?? `Expected ${type}, got '${tok.value}'`, tok.location)); + return tok; + } + + function expectValue(value: string, msg?: string): Token { + if (current().value === value) return advance(); + const tok = current(); + diagnostics.push(error('parse.expected', msg ?? `Expected '${value}', got '${tok.value}'`, tok.location)); + return tok; + } + + function loc(start: Token, end?: Token): Location { + const e = end ?? tokens[pos - 1] ?? start; + return { + line: start.location.line, + column: start.location.column, + offset: start.location.offset, + length: (e.location.offset + e.location.length) - start.location.offset, + }; + } + + // Peek ahead to check if a '{' starts a variant/dict (has key:value pairs) + // versus something else (match body, etc.) + function looksLikeVariantOrDict(): boolean { + const saved = pos; + pos++; // skip '{' + const result = at(TokenType.RBrace) || (at(TokenType.Identifier) && ( + peek(1).type === TokenType.Colon || // key: value + peek(1).type === TokenType.Comma || // shorthand: key, + peek(1).type === TokenType.RBrace // shorthand: key } + )); + pos = saved; + return result; + } + + // --- Top Level --- + + /** Break out of a loop if no progress was made since last check. */ + function guardProgress(lastPos: number): boolean { + if (pos === lastPos) { advance(); return false; } + return true; + } + + function parseProgram(): ProgramNode { + const body: Declaration[] = []; + while (!at(TokenType.EOF)) { + const before = pos; + try { + body.push(parseDeclaration()); + } catch { + // Recovery: skip to next declaration boundary + while (!at(TokenType.EOF) && !at(TokenType.Type) && !at(TokenType.Namespace) && !at(TokenType.Source) && !at(TokenType.Table) && !isExprDeclStart()) { + advance(); + } + } + if (!guardProgress(before)) continue; + } + return { kind: 'Program', body }; + } + + function isExprDeclStart(): boolean { + // An expression declaration starts with IDENT ( + return at(TokenType.Identifier) && peek(1).type === TokenType.LParen; + } + + function parseDeclaration(): Declaration { + if (at(TokenType.Type)) return parseTypeDeclaration(); + if (at(TokenType.Namespace)) return parseNamespaceDeclaration(); + if (at(TokenType.Source)) return parseSourceDeclaration(); + if (at(TokenType.Table)) return parseTableDeclaration(); + return parseExpressionDeclaration(); + } + + function parseTypeDeclaration(): TypeDeclaration { + const start = advance(); // 'type' + const name = expect(TokenType.Identifier).value; + expect(TokenType.Assign); + // Record type: type Name = { field: Type, ... } + if (at(TokenType.LBrace)) { + const shape = parseShapeFields(); + return { kind: 'TypeDeclaration', name, alternatives: [], shape, location: loc(start) }; + } + // Variant type: type Name = tag { ... } | tag { ... } + const alternatives: VariantAlternative[] = []; + const first = tryParseVariantAlternative(); + if (first) alternatives.push(first); + while (at(TokenType.Pipe)) { + advance(); + const alt = tryParseVariantAlternative(); + if (alt) alternatives.push(alt); + else break; // incomplete alternative (e.g. typing `| ` with no tag yet) + } + return { kind: 'TypeDeclaration', name, alternatives, location: loc(start) }; + } + + function tryParseVariantAlternative(): VariantAlternative | null { + if (!at(TokenType.Identifier)) { + // No tag yet — incomplete variant alternative, bail gracefully + if (!at(TokenType.EOF) && !at(TokenType.Pipe)) { + diagnostics.push(error('parse.expected', `Expected variant tag name, got '${current().value}'`, current().location)); + } + return null; + } + const tag = advance().value; + // Payload-less variant: tag without {} (next is | or newline/EOF/different statement) + if (!at(TokenType.LBrace)) { + return { tag, shape: {} }; + } + const shape = parseShapeFields(); + return { tag, shape }; + } + + function parseShapeFields(): Record { + expect(TokenType.LBrace); + const shape: Record = {}; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + if (!at(TokenType.Identifier)) { + // Unexpected token inside shape — skip it to avoid infinite loop + diagnostics.push(error('parse.expected', `Expected field name, got '${current().value}'`, current().location)); + advance(); + continue; + } + const fname = advance().value; + expect(TokenType.Colon); + shape[fname] = parseTypeAnnotation(); + if (!at(TokenType.RBrace)) expect(TokenType.Comma); + } + expect(TokenType.RBrace); + return shape; + } + + function parseTypeAnnotation(): TypeAnnotation { + // Handle qualified names: foo.bar.Baz + let keyword = expect(TokenType.Identifier).value; + while (at(TokenType.Dot) && peek(1).type === TokenType.Identifier) { + advance(); // '.' + keyword += '.' + advance().value; + } + const args: Expr[] = []; + + if (at(TokenType.LParen)) { + advance(); + while (!at(TokenType.RParen) && !at(TokenType.EOF)) { + const before = pos; + args.push(parseExpression()); + if (!at(TokenType.RParen)) expect(TokenType.Comma); + if (!guardProgress(before)) break; + } + expect(TokenType.RParen); + } + + return { keyword, args }; + } + + function parseNamespaceDeclaration(): NamespaceDeclaration { + const start = advance(); // 'namespace' + const name = expect(TokenType.Identifier).value; + expect(TokenType.LBrace); + const symbols: SymbolDeclaration[] = []; + const types: TypeDeclaration[] = []; + const expressions: ExpressionDeclaration[] = []; + const sources: SourceDeclaration[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + // Type declaration inside namespace + if (at(TokenType.Type)) { + types.push(parseTypeDeclaration()); + if (!guardProgress(before)) break; + continue; + } + // Source declaration inside namespace + if (at(TokenType.Source)) { + sources.push(parseSourceDeclaration()); + if (!guardProgress(before)) break; + continue; + } + // Expression declaration: Name(...) + if (isExprDeclStart()) { + expressions.push(parseExpressionDeclaration()); + if (!guardProgress(before)) break; + continue; + } + // Symbol declaration: Name: Type = Expression + const sname = expect(TokenType.Identifier).value; + expect(TokenType.Colon); + const stype = parseTypeAnnotation(); + expect(TokenType.Assign); + const value = parseExpression(); + symbols.push({ name: sname, type: stype, value }); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'NamespaceDeclaration', name, symbols, types, expressions, sources, location: loc(start) }; + } + + function parseExpressionDeclaration(): ExpressionDeclaration { + const start = current(); + const name = expect(TokenType.Identifier).value; + const params: Parameter[] = []; + + // Parameters are optional — parse only if ( follows + if (at(TokenType.LParen)) { + advance(); + while (!at(TokenType.RParen) && !at(TokenType.EOF)) { + const before = pos; + const pname = expect(TokenType.Identifier).value; + expect(TokenType.Colon); + let ptype: TypeAnnotation; + if (at(TokenType.LBrace)) { + // Inline shape parameter + ptype = { keyword: 'dict', args: [], shape: parseShapeFields() }; + } else { + ptype = parseTypeAnnotation(); + } + params.push({ name: pname, type: ptype }); + if (!at(TokenType.RParen)) expect(TokenType.Comma); + if (!guardProgress(before)) break; + } + expect(TokenType.RParen); + } + + let returnType: TypeAnnotation | undefined; + if (at(TokenType.Colon)) { + advance(); + returnType = parseTypeAnnotation(); + } + + expect(TokenType.LBrace); + const body = parseExpression(); + expect(TokenType.RBrace); + return { kind: 'ExpressionDeclaration', name, params, returnType, body, location: loc(start) }; + } + + function parseSourceDeclaration(): SourceDeclaration { + const start = advance(); // 'source' + const name = expect(TokenType.Identifier).value; + expect(TokenType.LParen); + const params: Parameter[] = []; + while (!at(TokenType.RParen) && !at(TokenType.EOF)) { + const before = pos; + const pname = expect(TokenType.Identifier).value; + expect(TokenType.Colon); + let ptype: TypeAnnotation; + if (at(TokenType.LBrace)) { + ptype = { keyword: 'dict', args: [], shape: parseShapeFields() }; + } else { + ptype = parseTypeAnnotation(); + } + params.push({ name: pname, type: ptype }); + if (!at(TokenType.RParen)) expect(TokenType.Comma); + if (!guardProgress(before)) break; + } + expect(TokenType.RParen); + expect(TokenType.Colon); + let returnType: TypeAnnotation; + if (at(TokenType.LBrace)) { + returnType = { keyword: 'dict', args: [], shape: parseShapeFields() }; + } else { + returnType = parseTypeAnnotation(); + } + return { kind: 'SourceDeclaration', name, params, returnType, location: loc(start) }; + } + + function parseTableDeclaration(): TableDeclaration { + const start = advance(); // 'table' + const name = expect(TokenType.Identifier).value; + expect(TokenType.Colon); + // Expect list({field: type, ...}) or list(TypeName) + const keyword = expect(TokenType.Identifier).value; + let elementType: TypeAnnotation; + if (keyword === 'list' && at(TokenType.LParen)) { + advance(); // '(' + if (at(TokenType.LBrace)) { + // list({field: type, ...}) — inline record shape + const shape = parseShapeFields(); + elementType = { keyword: 'dict', args: [], shape }; + } else { + // list(TypeName) — named element type + elementType = parseTypeAnnotation(); + } + expect(TokenType.RParen); + } else { + // Bare type name (shouldn't normally happen for tables) + elementType = { keyword, args: [] }; + } + return { kind: 'TableDeclaration', name, elementType, location: loc(start) }; + } + + // --- Expressions --- + + function parseExpression(): Expr { + let expr = parseExpressionInner(); + + // where clause: expr where name = expr, name2 = expr2 + if (at(TokenType.Where)) { + const start = advance(); // 'where' + const bindings: { name: string; value: Expr }[] = []; + do { + const bname = expect(TokenType.Identifier).value; + expect(TokenType.Assign); + bindings.push({ name: bname, value: parseExpressionInner() }); + } while (at(TokenType.Comma) && advance()); + expr = { kind: 'WhereExpression', body: expr, bindings, location: loc(start) }; + } + + return expr; + } + + /** Parse an expression without consuming a trailing `where` clause. */ + function parseExpressionInner(): Expr { + if (at(TokenType.If)) return parseIfExpression(); + if (at(TokenType.Match)) return parseMatchExpression(); + return parseInfixExpression(0); + } + + function parseIfExpression(): Expr { + const start = advance(); // 'if' + const condition = parseExpressionInner(); + expectValue('then'); + const thenExpr = parseExpressionInner(); + const elseIfs: { condition: Expr; then: Expr }[] = []; + while (at(TokenType.Else) && peek(1).type === TokenType.If) { + advance(); // 'else' + advance(); // 'if' + const eiCond = parseExpressionInner(); + expectValue('then'); + const eiThen = parseExpressionInner(); + elseIfs.push({ condition: eiCond, then: eiThen }); + } + expect(TokenType.Else); + const elseExpr = parseExpressionInner(); + return { kind: 'IfExpression', condition, then: thenExpr, elseIfs, else: elseExpr, location: loc(start) }; + } + + function parseMatchExpression(): MatchExpr { + const start = advance(); // 'match' + + // match binding in iterable { ... } — iteration form + if (at(TokenType.Identifier) && peek(1).type === TokenType.In) { + const binding = advance().value; // binding name + advance(); // 'in' + const iterable = parseInfixExpression(0); + expect(TokenType.LBrace); + const arms: MatchArm[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const pattern = parsePattern(); + expect(TokenType.Arrow); + const expression = parseExpression(); + arms.push({ pattern, expression }); + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'MatchExpression', binding, iterable, arms, location: loc(start) }; + } + + // Standard match: subject or subjectless + let subject: Expr | undefined; + if (!at(TokenType.LBrace)) { + // Tuple subject: match (a, b, c) { ... } + if (at(TokenType.LParen)) { + const tupleStart = current(); + advance(); // '(' + const first = parseExpression(); + if (at(TokenType.Comma)) { + // It's a tuple + const elements: Expr[] = [first]; + while (at(TokenType.Comma)) { + advance(); + elements.push(parseExpression()); + } + expect(TokenType.RParen); + subject = { kind: 'ListLiteral', elements, location: loc(tupleStart) }; + } else { + // Single parenthesized expression + expect(TokenType.RParen); + subject = { kind: 'ParenExpression', expression: first, location: loc(tupleStart) }; + } + } else { + subject = parseInfixExpression(0); + } + } + expect(TokenType.LBrace); + const arms: MatchArm[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const pattern = parsePattern(); + expect(TokenType.Arrow); + const expression = parseExpression(); + arms.push({ pattern, expression }); + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'MatchExpression', subject, arms, location: loc(start) }; + } + + // --- Pratt parser for infix expressions --- + + const PRECEDENCE: Record = { + '||': 1, + '&&': 2, + '==': 3, '!=': 3, + '<': 4, '>': 4, '<=': 4, '>=': 4, + 'in': 4, 'not in': 4, + '+': 5, '-': 5, + '*': 6, '/': 6, '%': 6, + '**': 7, + }; + + function getInfixOp(): { op: string; prec: number } | null { + // 'not in' as two-token operator + if (at(TokenType.Not) && peek(1).type === TokenType.In) { + return { op: 'not in', prec: PRECEDENCE['not in'] }; + } + if (at(TokenType.In)) return { op: 'in', prec: PRECEDENCE['in'] }; + + const opMap: Partial> = { + [TokenType.Plus]: '+', [TokenType.Minus]: '-', + [TokenType.Star]: '*', [TokenType.Slash]: '/', + [TokenType.Percent]: '%', [TokenType.StarStar]: '**', + [TokenType.Eq]: '==', [TokenType.NotEq]: '!=', + [TokenType.Lt]: '<', [TokenType.Gt]: '>', + [TokenType.LtEq]: '<=', [TokenType.GtEq]: '>=', + [TokenType.And]: '&&', [TokenType.Or]: '||', + }; + const op = opMap[current().type]; + if (op) return { op, prec: PRECEDENCE[op] }; + return null; + } + + function parseInfixExpression(minPrec: number): Expr { + let left = parseUnaryExpression(); + + while (true) { + const inf = getInfixOp(); + if (!inf || inf.prec < minPrec) break; + + const start = current(); + if (inf.op === 'not in') { advance(); advance(); } + else advance(); + + // Right-associative for ** + const nextPrec = inf.op === '**' ? inf.prec : inf.prec + 1; + const right = parseInfixExpression(nextPrec); + left = { kind: 'InfixExpression', left, operator: inf.op, right, location: loc(start) }; + } + + return left; + } + + function parseUnaryExpression(): Expr { + if (at(TokenType.Not) && peek(1).type !== TokenType.In) { + const start = advance(); + const operand = parseUnaryExpression(); + return { kind: 'UnaryExpression', operator: 'not', operand, location: loc(start) }; + } + if (at(TokenType.Bang)) { + const start = advance(); + const operand = parseUnaryExpression(); + return { kind: 'UnaryExpression', operator: '!', operand, location: loc(start) }; + } + if (at(TokenType.Minus)) { + const start = advance(); + const operand = parseUnaryExpression(); + return { kind: 'UnaryExpression', operator: '-', operand, location: loc(start) }; + } + return parsePostfixExpression(); + } + + function parsePostfixExpression(): Expr { + let expr = parsePrimary(); + + while (true) { + if (at(TokenType.Dot)) { + advance(); + const prop = expect(TokenType.Identifier).value; + expr = { kind: 'MemberExpression', object: expr, property: prop, location: expr.location }; + } else if (at(TokenType.LBracket)) { + advance(); + const index = parseExpression(); + expect(TokenType.RBracket); + expr = { kind: 'IndexExpression', object: expr, index, location: expr.location }; + } else if (at(TokenType.As)) { + advance(); + const targetType = parseTypeAnnotation(); + expr = { kind: 'CoercionExpression', expression: expr, targetType, location: expr.location }; + } else { + break; + } + } + + return expr; + } + + function parsePrimary(): Expr { + const start = current(); + + // any PATTERN in EXPR + if (at(TokenType.Any)) { + advance(); + const pattern = parsePattern(true); + expectValue('in'); + const list = parseInfixExpression(0); + return { kind: 'AnyExpression', pattern, list, location: loc(start) }; + } + + // all PATTERN in EXPR + if (at(TokenType.All)) { + advance(); + const pattern = parsePattern(true); + expectValue('in'); + const list = parseInfixExpression(0); + return { kind: 'AllExpression', pattern, list, location: loc(start) }; + } + + // collect PATTERN in EXPR => BODY (pattern form — destructure/filter) + // collect BINDING in EXPR => BODY (map form — bind each element, transform all) + // collect BINDING in EXPR { ARMS } (binding form — filter + transform with arms) + if (at(TokenType.Collect)) { + advance(); + + // Detect binding/map form: collect identifier in expr ... + if (at(TokenType.Identifier) && peek(1).type === TokenType.In) { + const identTok = advance(); + advance(); // 'in' + const list = parseInfixExpression(0); + if (at(TokenType.LBrace)) { + // Binding form with arms: collect row in list { condition => body, ... } + expect(TokenType.LBrace); + const arms: { pattern: Pattern; body: Expr }[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const armPattern = parsePattern(); + expect(TokenType.Arrow); + const armBody = parseExpression(); + arms.push({ pattern: armPattern, body: armBody }); + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'CollectExpression', list, binding: identTok.value, arms, location: loc(start) }; + } + // Map form: collect ident in list => body (bind each element, transform all) + expect(TokenType.Arrow); + const body = parseExpression(); + const wildcardArm: { pattern: Pattern; body: Expr } = { + pattern: { kind: 'WildcardPattern', location: identTok.location }, + body, + }; + return { kind: 'CollectExpression', list, binding: identTok.value, arms: [wildcardArm], location: loc(start) }; + } + + const pattern = parsePattern(true); + expectValue('in'); + const list = parseInfixExpression(0); + expect(TokenType.Arrow); + const body = parseExpression(); + return { kind: 'CollectExpression', pattern, list, body, location: loc(start) }; + } + + // Nested if/match + if (at(TokenType.If)) return parseIfExpression(); + if (at(TokenType.Match)) return parseMatchExpression(); + + // Plugin literal (e.g., money: £100, GBP50.25) + if (at(TokenType.PluginLiteral)) { + const tok = advance(); + return { kind: 'PluginLiteral', tag: tok.tag!, value: tok.payload, displayValue: tok.value, location: tok.location }; + } + + // Number literal + if (at(TokenType.Number)) { + const tok = advance(); + return { kind: 'Literal', value: parseFloat(tok.value), raw: tok.value, location: tok.location }; + } + + // String literal + if (at(TokenType.String)) { + const tok = advance(); + return { kind: 'Literal', value: tok.value, raw: `"${tok.value}"`, location: tok.location }; + } + + // Bool literal + if (at(TokenType.Bool)) { + const tok = advance(); + return { kind: 'Literal', value: tok.value === 'true', raw: tok.value, location: tok.location }; + } + + // List literal + if (at(TokenType.LBracket)) { + advance(); + const elements: Expr[] = []; + while (!at(TokenType.RBracket) && !at(TokenType.EOF)) { + const before = pos; + elements.push(parseExpression()); + if (!at(TokenType.RBracket)) expect(TokenType.Comma); + if (!guardProgress(before)) break; + } + expect(TokenType.RBracket); + return { kind: 'ListLiteral', elements, location: loc(start) }; + } + + // Parenthesized expression or range pattern used as expression + if (at(TokenType.LParen)) { + advance(); + const inner = parseExpression(); + expect(TokenType.RParen); + return { kind: 'ParenExpression', expression: inner, location: loc(start) }; + } + + // Identifier, call, variant construction, aggregate collect, or dict + if (at(TokenType.Identifier)) { + // Look ahead for aggregate collect: IDENT collect ... + if (peek(1).type === TokenType.Collect) { + const aggName = advance().value; + advance(); // 'collect' + + // Detect binding form: agg collect identifier in expr { arms } + let binding: string | undefined; + if (at(TokenType.Identifier) && peek(1).type === TokenType.In) { + binding = advance().value; + } + + expectValue('in'); + const list = parseInfixExpression(0); + // Map form: agg collect ident in list => body + if (binding && at(TokenType.Arrow)) { + advance(); // '=>' + const mapBody = parseExpression(); + const wildcardArm: { pattern: Pattern; body: Expr } = { + pattern: { kind: 'WildcardPattern', location: loc(start) }, + body: mapBody, + }; + return { kind: 'AggregateCollectExpression', aggregator: aggName, list, arms: [wildcardArm], binding, location: loc(start) }; + } + // Arms form: agg collect ... in list { arms } + expect(TokenType.LBrace); + const arms: { pattern: Pattern; body: Expr }[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const pattern = parsePattern(); + expect(TokenType.Arrow); + const body = parseExpression(); + arms.push({ pattern, body }); + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'AggregateCollectExpression', aggregator: aggName, list, arms, binding, location: loc(start) }; + } + + // Resolve qualified name: a.b.c + // Only consume dots when this is clearly a qualified name for a call/variant/type, + // NOT member access like quote.field + let name = advance().value; + while (at(TokenType.Dot) && peek(1).type === TokenType.Identifier) { + const savedPos = pos; + advance(); // '.' + const next = advance().value; + const tentativeName = name + '.' + next; + + if (at(TokenType.Dot) && peek(1).type === TokenType.Identifier) { + // More dots coming — keep going, might be a.b.c.Tag + name = tentativeName; + continue; + } + if (at(TokenType.LParen)) { + // Qualified call: Name.Sub(...) + name = tentativeName; + break; + } + if (at(TokenType.LBrace)) { + // Could be variant construction: tag { ... } + // or member access followed by something else: expr.field { match body } + // Peek inside braces: variant/dict has IDENT ':' or is empty '{}' + if (looksLikeVariantOrDict()) { + name = tentativeName; + break; + } + // Not a variant — this is member access, roll back + pos = savedPos; + break; + } + // No call/variant follows — it's member access, roll back + pos = savedPos; + break; + } + + // Call expression: Name(...) + if (at(TokenType.LParen)) { + advance(); + const args: Expr[] = []; + const namedArgs: Record = {}; + const allArgs: Expr[] = []; + let spread = false; + // Look ahead for spread or named args — enables shorthand named args + let hasNamedOrSpread = false; + for (let j = pos; j < tokens.length; j++) { + if (tokens[j].type === TokenType.RParen || tokens[j].type === TokenType.EOF) break; + if (tokens[j].type === TokenType.Spread) { hasNamedOrSpread = true; break; } + if (tokens[j].type === TokenType.Identifier && j + 1 < tokens.length && tokens[j + 1].type === TokenType.Colon) { hasNamedOrSpread = true; break; } + } + while (!at(TokenType.RParen) && !at(TokenType.EOF)) { + const before = pos; + // Spread: ... fills remaining params from scope + if (at(TokenType.Spread)) { + advance(); + spread = true; + } + // Check for named arg: IDENT ':' + else if (at(TokenType.Identifier) && peek(1).type === TokenType.Colon) { + const argName = advance().value; + advance(); // ':' + const argExpr = parseExpression(); + namedArgs[argName] = argExpr; + allArgs.push(argExpr); + } + // Shorthand named arg: bare IDENT followed by , or ) — only with named args or spread + else if (hasNamedOrSpread && at(TokenType.Identifier) && (peek(1).type === TokenType.Comma || peek(1).type === TokenType.RParen)) { + const tok = advance(); + const argExpr: Expr = { kind: 'Identifier', name: tok.value, location: tok.location }; + namedArgs[tok.value] = argExpr; + allArgs.push(argExpr); + } + else { + const argExpr = parseExpression(); + args.push(argExpr); + allArgs.push(argExpr); + } + if (!at(TokenType.RParen)) expect(TokenType.Comma); + if (!guardProgress(before)) break; + } + expect(TokenType.RParen); + const node: any = { kind: 'CallExpression', callee: name, args, namedArgs, allArgs, location: loc(start) }; + if (spread) node.spread = true; + return node; + } + + // Variant construction or dict: Name { ... } + if (at(TokenType.LBrace) && looksLikeVariantOrDict()) { + advance(); // '{' + + // Empty braces = variant with empty payload + if (at(TokenType.RBrace)) { + advance(); + const typeParts = name.split('.'); + const tag = typeParts.pop()!; + const typeName = typeParts.length > 0 ? typeParts.join('.') : undefined; + return { kind: 'VariantConstruction', typeName, tag, entries: [], location: loc(start) }; + } + + // It's key:value — variant or dict + { + const entries: { key: string; value: Expr }[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const key = expect(TokenType.Identifier).value; + // Shorthand: bare identifier without ':' means key: key + if (at(TokenType.Comma) || at(TokenType.RBrace)) { + entries.push({ key, value: { kind: 'Identifier', name: key, location: tokens[pos - 1].location } }); + } else { + expect(TokenType.Colon); + const value = parseExpression(); + entries.push({ key, value }); + } + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + + const typeParts = name.split('.'); + const tag = typeParts.pop()!; + const typeName = typeParts.length > 0 ? typeParts.join('.') : undefined; + return { kind: 'VariantConstruction', typeName, tag, entries, location: loc(start) }; + } + } + + // Plain identifier + return { kind: 'Identifier', name, location: start.location }; + } + + // Dict literal without a preceding identifier: { key: value, ... } + if (at(TokenType.LBrace)) { + advance(); + const entries: { key: string; value: Expr }[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + let key: string; + if (at(TokenType.String)) { + key = advance().value; + } else { + key = expect(TokenType.Identifier).value; + } + // Shorthand: bare identifier without ':' means key: key + if (!key.startsWith('"') && (at(TokenType.Comma) || at(TokenType.RBrace))) { + entries.push({ key, value: { kind: 'Identifier', name: key, location: tokens[pos - 1].location } }); + } else { + expect(TokenType.Colon); + const value = parseExpression(); + entries.push({ key, value }); + } + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'DictLiteral', entries, location: loc(start) }; + } + + diagnostics.push(error('parse.unexpected_token', `Unexpected token '${current().value}'`, current().location)); + advance(); + return { kind: 'Literal', value: 0, raw: '0', location: start.location }; + } + + // --- Patterns --- + + function parsePattern(inCollectionForm: boolean = false): Pattern { + const start = current(); + const first = parseSinglePattern(inCollectionForm); + if (!at(TokenType.Pipe)) return first; + const patterns: Pattern[] = [first]; + while (at(TokenType.Pipe)) { + advance(); + patterns.push(parseSinglePattern(inCollectionForm)); + } + return { kind: 'AlternativePattern', patterns, location: loc(start) }; + } + + function parseSinglePattern(inCollectionForm: boolean = false): Pattern { + const start = current(); + + // Wildcard + if (at(TokenType.Underscore)) { + advance(); + return { kind: 'WildcardPattern', location: start.location }; + } + + // Range pattern: (lo..hi] or [lo..hi) etc. + if (at(TokenType.LParen) || at(TokenType.LBracket)) { + const mightBeRange = tryParseRangePattern(); + if (mightBeRange) return mightBeRange; + } + + // Tuple pattern: (pat1, pat2, ...) + if (at(TokenType.LParen)) { + const tupleStart = current(); + advance(); // '(' + const elements: Pattern[] = [parsePattern()]; + while (at(TokenType.Comma)) { + advance(); + elements.push(parsePattern()); + } + expect(TokenType.RParen); + if (elements.length >= 2) { + return { kind: 'TuplePattern', elements, location: loc(tupleStart) }; + } + // Single element — treat as the inner pattern + return elements[0]; + } + + // Variant pattern or literal pattern: IDENT { bindings } + if (at(TokenType.Identifier)) { + // Resolve qualified: a.b.Tag + let name = current().value; + const savedPos = pos; + advance(); + + while (at(TokenType.Dot) && peek(1).type === TokenType.Identifier) { + advance(); + name += '.' + advance().value; + } + + if (at(TokenType.LBrace)) { + // Variant pattern with field bindings + advance(); + const bindings: Record = {}; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const fieldName = expect(TokenType.Identifier).value; + if (at(TokenType.Colon)) { + advance(); + if (at(TokenType.Underscore)) { + advance(); + bindings[fieldName] = null; // wildcard binding + } else { + bindings[fieldName] = expect(TokenType.Identifier).value; + } + } else { + bindings[fieldName] = fieldName; // shorthand: name binds to name + } + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + + const parts = name.split('.'); + const tag = parts.pop()!; + const typeName = parts.length > 0 ? parts.join('.') : undefined; + return { kind: 'VariantPattern', typeName, tag, bindings, location: loc(start) }; + } + + // Bare tag pattern: `referred` without braces, in pattern-terminating context + // Only treat `in` as a terminator inside collection forms (any/all/collect), + // not in match arms where `x in [list]` is a valid expression pattern. + const inTerminates = inCollectionForm && at(TokenType.In); + if (inTerminates || at(TokenType.Arrow) || at(TokenType.Comma) || at(TokenType.RBrace)) { + const parts = name.split('.'); + const tag = parts.pop()!; + const typeName = parts.length > 0 ? parts.join('.') : undefined; + return { kind: 'VariantPattern', typeName, tag, bindings: {}, location: loc(start) }; + } + + // Not a variant pattern — might be an expression pattern + pos = savedPos; + } + + // Number literal pattern + if (at(TokenType.Number)) { + const tok = advance(); + return { kind: 'LiteralPattern', value: parseFloat(tok.value), raw: tok.value, location: tok.location }; + } + + // String literal pattern + if (at(TokenType.String)) { + const tok = advance(); + return { kind: 'LiteralPattern', value: tok.value, raw: `"${tok.value}"`, location: tok.location }; + } + + // Bool literal pattern + if (at(TokenType.Bool)) { + const tok = advance(); + return { kind: 'LiteralPattern', value: tok.value === 'true', raw: tok.value, location: tok.location }; + } + + // Expression pattern (fallback) + const expr = parseInfixExpression(0); + return { kind: 'ExpressionPattern', expression: expr, location: expr.location }; + } + + function tryParseRangePattern(): Pattern | null { + const saved = pos; + const start = current(); + const openLeft = at(TokenType.LParen); // ( = exclusive + advance(); // ( or [ + + let left: number | undefined; + let right: number | undefined; + + // Optional left number + if (at(TokenType.Number)) { + left = parseFloat(advance().value); + } + + // Expect .. + if (!at(TokenType.DotDot)) { pos = saved; return null; } + advance(); // .. + + // Optional right number + if (at(TokenType.Number)) { + right = parseFloat(advance().value); + } + + // At least one bound must be present + if (left === undefined && right === undefined) { pos = saved; return null; } + + const openRight = at(TokenType.RParen); // ) = exclusive + if (!at(TokenType.RBracket) && !at(TokenType.RParen)) { pos = saved; return null; } + advance(); // ] or ) + + return { + kind: 'RangePattern', + openLeft, + openRight, + left, + right, + location: loc(start), + }; + } + + const ast = parseProgram(); + return { ast, diagnostics }; +} diff --git a/playground/src/lang/plugin.ts b/playground/src/lang/plugin.ts new file mode 100644 index 0000000..30f7932 --- /dev/null +++ b/playground/src/lang/plugin.ts @@ -0,0 +1,43 @@ +import { TypeSig } from './types'; + +/** Plugin hook for custom token recognition in the lexer. */ +export interface LexerPlugin { + /** Try to recognize a custom token at position `pos` in source. + * Return null if this position isn't handled by this plugin. */ + tryTokenize(source: string, pos: number): { + tag: string; // e.g., 'money' + value: string; // display value, e.g., '£100.50' + payload: unknown; // structured data for AST/evaluator + length: number; // chars consumed from source + } | null; +} + +/** Plugin hook for type checking. */ +export interface CheckerPlugin { + /** Infer the type of a plugin literal. */ + inferLiteralType?(tag: string, payload: unknown): TypeSig | null; + /** Check a binary operator with the given operand types. + * Return the result type, { error: string } for a type error, or null to defer to defaults. */ + checkBinaryOp?(op: string, left: TypeSig, right: TypeSig): TypeSig | { error: string } | null; + /** Check an intrinsic/function call with the given arg types. + * Return the result type, or null to defer to default checking. */ + checkCall?(name: string, argTypes: TypeSig[]): TypeSig | null; +} + +/** Plugin hook for evaluation. */ +export interface EvaluatorPlugin { + /** Return true if this plugin handles the given binary operation. */ + supportsOp?(left: unknown, right: unknown, op: string): boolean; + /** Evaluate a binary operation. Only called if supportsOp returned true. */ + evaluateOp?(left: unknown, right: unknown, op: string): unknown; + /** Plugin-provided intrinsic overrides. Return undefined to fall through to built-in. */ + intrinsics?: Record unknown) | undefined>; +} + +/** An Axiom plugin bundles lexer, checker, and evaluator extensions. */ +export interface AxiomPlugin { + name: string; + lexer?: LexerPlugin; + checker?: CheckerPlugin; + evaluator?: EvaluatorPlugin; +} diff --git a/playground/src/lang/types.ts b/playground/src/lang/types.ts new file mode 100644 index 0000000..f8077f9 --- /dev/null +++ b/playground/src/lang/types.ts @@ -0,0 +1,132 @@ +export interface TypeSig { + name: string; + params: string[]; + shape?: Record; + variants?: Record>; // tag -> shape + isPluginProvided?: boolean; + elementType?: TypeSig; // Full element type for list(T), preserved through nesting +} + +export const TYPE_NUMBER: TypeSig = { name: 'number', params: [] }; +export const TYPE_STRING: TypeSig = { name: 'string', params: [] }; +export const TYPE_BOOL: TypeSig = { name: 'bool', params: [] }; +export const TYPE_MIXED: TypeSig = { name: 'mixed', params: [] }; + +export function typeList(element?: TypeSig): TypeSig { + return { name: 'list', params: element ? [element.name] : [], elementType: element }; +} + +export function typeDict(shape?: Record, valueType?: TypeSig): TypeSig { + return { name: 'dict', params: valueType ? [valueType.name] : [], shape, elementType: valueType }; +} + +export function typeVariant(name: string, variants: Record>): TypeSig { + return { name, params: [], variants }; +} + +export function typeMoney(currency: string): TypeSig { + // For the PoC, money is just a number alias with a label + return { name: 'money', params: [currency] }; +} + +export function isAssignable(source: TypeSig, target: TypeSig): boolean { + if (target.name === 'mixed') return true; + + if (source.name === target.name) { + // List: check element type compatibility + if (source.name === 'list') { + // Untyped list or mixed-element list is assignable to any list + if (source.params.length === 0 || target.params.length === 0) return true; + if (source.params[0] === 'mixed' || target.params[0] === 'mixed') return true; + // Use full elementType for deep comparison when available + if (source.elementType && target.elementType) { + return isAssignable(source.elementType, target.elementType); + } + // Element type names must match (nominal) + return source.params[0] === target.params[0]; + } + // Dict: check value type compatibility + if (source.name === 'dict') { + if (source.params.length === 0 || target.params.length === 0) return true; + if (source.params[0] === 'mixed' || target.params[0] === 'mixed') return true; + if (source.elementType && target.elementType) { + return isAssignable(source.elementType, target.elementType); + } + return source.params[0] === target.params[0]; + } + // Money: check currency + if (source.name === 'money') { + return source.params[0] === target.params[0] || target.params.length === 0; + } + // Same-named variant types are the same type (nominal) + return true; + } + + // number <-> money interop for PoC + if (source.name === 'number' && target.name === 'money') return true; + if (source.name === 'money' && target.name === 'number') return true; + + // A variant value inferred from construction (anonymous/structural) is + // assignable to a named variant if the source is the SAME named type, + // OR if the source was resolved to the target type by the checker. + // For nominal typing: different names = different types, even if + // structurally identical. + if (source.variants && target.variants) { + // Only assignable if they share the same type name + // This is the nominal check. + if (source.name === target.name) return true; + // Allow anonymous/ad-hoc variants (no declared type) to match if tags AND fields match + const sourceIsAnonymous = !source.name || source.name === Object.keys(source.variants)[0]; + if (sourceIsAnonymous) { + for (const tag of Object.keys(source.variants)) { + if (!(tag in target.variants)) return false; + const sourceShape = source.variants[tag]; + const targetShape = target.variants[tag]; + // All target fields must exist in source with compatible types + for (const [field, targetFieldType] of Object.entries(targetShape)) { + if (!(field in sourceShape)) return false; + if (!isAssignable(sourceShape[field], targetFieldType)) return false; + } + // No extra fields in source + for (const field of Object.keys(sourceShape)) { + if (!(field in targetShape)) return false; + } + } + return true; + } + return false; + } + + return false; +} + +export function typeToString(t: TypeSig): string { + if (t.name === 'list' && t.elementType) { + return `list(${typeToString(t.elementType)})`; + } + if (t.name === 'dict' && t.elementType) { + return `dict(${typeToString(t.elementType)})`; + } + if (t.params.length > 0) return `${t.name}(${t.params.join(', ')})`; + if (t.shape) { + const fields = Object.entries(t.shape).map(([k, v]) => `${k}: ${typeToString(v)}`); + return `{ ${fields.join(', ')} }`; + } + if (t.variants) { + // If the type has a meaningful name (not just the first tag), show it + const tags = Object.keys(t.variants); + const isNamed = t.name && t.name !== tags[0]; + if (isNamed) return t.name; + const alts = Object.entries(t.variants).map(([tag, shape]) => { + const fields = Object.entries(shape).map(([k, v]) => `${k}: ${typeToString(v)}`); + return `${tag} { ${fields.join(', ')} }`; + }); + return alts.join(' | '); + } + return t.name; +} + +export function propertyType(t: TypeSig, prop: string): TypeSig | null { + if (t.shape && t.shape[prop]) return t.shape[prop]; + return null; +} diff --git a/playground/src/main.ts b/playground/src/main.ts new file mode 100644 index 0000000..824726a --- /dev/null +++ b/playground/src/main.ts @@ -0,0 +1,269 @@ +import * as monaco from 'monaco-editor'; +import { registerAxiomLanguage } from './editor/language'; +import { registerAxiomTheme } from './editor/theme'; +import { tokenize } from './lang/lexer'; +import { parse } from './lang/parser'; +import { check } from './lang/checker'; +import { evaluate } from './lang/evaluator'; +import { INSURANCE_EXAMPLE, INSURANCE_INPUT } from './examples/insurance.axiom'; +import { HEALTHCARE_EXAMPLE, HEALTHCARE_INPUT } from './examples/healthcare.axiom'; +import { TRADESPEOPLE_EXAMPLE, TRADESPEOPLE_INPUT } from './examples/tradespeople.axiom'; +import { HOSPITALITY_EXAMPLE, HOSPITALITY_INPUT } from './examples/hospitality.axiom'; +import { LANDLORDS_EXAMPLE, LANDLORDS_INPUT } from './examples/landlords.axiom'; +import { MONEY_EXAMPLE, MONEY_INPUT } from './examples/money.axiom'; +import { ProgramNode, ExpressionDeclaration } from './lang/ast'; +import { AxiomPlugin } from './lang/plugin'; +import { moneyPlugin } from './plugins/money'; +import { isMoneyValue, formatMoney } from './plugins/money'; +import { Diagnostic } from './lang/diagnostics'; + +// Monaco worker setup +self.MonacoEnvironment = { + getWorkerUrl(_moduleId: string, label: string) { + if (label === 'json') { + return new URL('monaco-editor/esm/vs/language/json/json.worker.js', import.meta.url).href; + } + if (label === 'css' || label === 'scss' || label === 'less') { + return new URL('monaco-editor/esm/vs/language/css/css.worker.js', import.meta.url).href; + } + if (label === 'html' || label === 'handlebars' || label === 'razor') { + return new URL('monaco-editor/esm/vs/language/html/html.worker.js', import.meta.url).href; + } + if (label === 'typescript' || label === 'javascript') { + return new URL('monaco-editor/esm/vs/language/typescript/ts.worker.js', import.meta.url).href; + } + return new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url).href; + }, +}; + +// Register language and theme +registerAxiomLanguage(); +registerAxiomTheme(); + +// Create editor +const editorContainer = document.getElementById('editor-container')!; +const editor = monaco.editor.create(editorContainer, { + value: INSURANCE_EXAMPLE, + language: 'axiom', + theme: 'axiom-dark', + fontSize: 14, + fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace", + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + padding: { top: 12 }, + renderLineHighlight: 'line', + bracketPairColorization: { enabled: true }, + autoIndent: 'full', + tabSize: 4, + insertSpaces: true, + wordWrap: 'on', + smoothScrolling: true, + cursorBlinking: 'smooth', + cursorSmoothCaretAnimation: 'on', +}); + +// Elements +const exampleSelect = document.getElementById('example-select') as HTMLSelectElement; +const exprSelect = document.getElementById('expr-select') as HTMLSelectElement; +const inputTextarea = document.getElementById('input-data') as HTMLTextAreaElement; +const outputPre = document.getElementById('output')!; +const diagnosticsPre = document.getElementById('diagnostics')!; + +const EXAMPLES: Record }> = { + insurance: { code: INSURANCE_EXAMPLE, input: INSURANCE_INPUT }, + healthcare: { code: HEALTHCARE_EXAMPLE, input: HEALTHCARE_INPUT }, + tradespeople: { code: TRADESPEOPLE_EXAMPLE, input: TRADESPEOPLE_INPUT }, + hospitality: { code: HOSPITALITY_EXAMPLE, input: HOSPITALITY_INPUT }, + landlords: { code: LANDLORDS_EXAMPLE, input: LANDLORDS_INPUT }, + money: { code: MONEY_EXAMPLE, input: MONEY_INPUT }, +}; + +const PLUGINS: AxiomPlugin[] = [moneyPlugin]; + +function loadExample(name: string) { + const example = EXAMPLES[name]; + if (!example) return; + editor.setValue(example.code); + inputTextarea.value = JSON.stringify(example.input, null, 2); +} + +// Set default input +inputTextarea.value = JSON.stringify(INSURANCE_INPUT, null, 2); + +// Resizer +const resizer = document.getElementById('resizer')!; +const outputPane = document.querySelector('.output-pane') as HTMLElement; +let isResizing = false; + +resizer.addEventListener('mousedown', () => { isResizing = true; }); +document.addEventListener('mousemove', (e) => { + if (!isResizing) return; + const containerWidth = document.querySelector('.main')!.getBoundingClientRect().width; + const newWidth = containerWidth - e.clientX; + outputPane.style.width = Math.max(250, Math.min(newWidth, containerWidth - 300)) + 'px'; + editor.layout(); +}); +document.addEventListener('mouseup', () => { isResizing = false; }); + +// Processing pipeline +let debounceTimer: number | undefined; + +function processCode() { + const source = editor.getValue(); + + // 1. Tokenize + const { tokens, diagnostics: lexDiags } = tokenize(source, PLUGINS); + + // 2. Parse + const { ast, diagnostics: parseDiags } = parse(tokens); + const allDiags = [...lexDiags, ...parseDiags]; + + // 3. Type check + const checkResult = check(ast, PLUGINS); + allDiags.push(...checkResult.diagnostics); + + // 4. Update expression selector + updateExpressionSelector(ast); + + // 5. Show diagnostics in Monaco + showDiagnostics(allDiags); + + // 6. Evaluate + const selectedExpr = exprSelect.value; + if (selectedExpr) { + tryEvaluate(ast, selectedExpr); + } else { + outputPre.textContent = 'Select an expression to evaluate'; + outputPre.style.color = '#6c7086'; + } +} + +function updateExpressionSelector(ast: ProgramNode) { + const previous = exprSelect.value; + const exprs = ast.body + .filter((d): d is ExpressionDeclaration => d.kind === 'ExpressionDeclaration') + .map(d => d.name); + + // Only update if the list changed + const currentOptions = Array.from(exprSelect.options).map(o => o.value); + if (JSON.stringify(exprs) !== JSON.stringify(currentOptions)) { + exprSelect.innerHTML = ''; + for (const name of exprs) { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + exprSelect.appendChild(option); + } + // Restore previous selection or default to last + if (exprs.includes(previous)) { + exprSelect.value = previous; + } else if (exprs.length > 0) { + exprSelect.value = exprs[exprs.length - 1]; + } + } +} + +function showDiagnostics(diags: Diagnostic[]) { + // Monaco markers + const model = editor.getModel()!; + const markers: monaco.editor.IMarkerData[] = diags + .filter(d => d.location) + .map(d => { + const startPos = model.getPositionAt(d.location!.offset); + const endPos = model.getPositionAt(d.location!.offset + Math.max(d.location!.length, 1)); + return { + severity: d.severity === 'error' + ? monaco.MarkerSeverity.Error + : d.severity === 'warning' + ? monaco.MarkerSeverity.Warning + : monaco.MarkerSeverity.Info, + startLineNumber: startPos.lineNumber, + startColumn: startPos.column, + endLineNumber: endPos.lineNumber, + endColumn: endPos.column, + message: d.message, + code: d.code, + }; + }); + monaco.editor.setModelMarkers(model, 'axiom', markers); + + // Diagnostics panel + const errors = diags.filter(d => d.severity === 'error'); + const warnings = diags.filter(d => d.severity === 'warning'); + + if (errors.length === 0 && warnings.length === 0) { + diagnosticsPre.textContent = 'No issues'; + diagnosticsPre.className = 'clean'; + } else { + const lines: string[] = []; + for (const d of [...errors, ...warnings]) { + const loc = d.location ? `[${d.location.line}:${d.location.column}]` : ''; + const prefix = d.severity === 'error' ? 'ERROR' : 'WARN'; + lines.push(`${prefix} ${loc} ${d.message}`); + } + diagnosticsPre.textContent = lines.join('\n'); + diagnosticsPre.className = errors.length > 0 ? '' : 'clean'; + if (errors.length > 0) { + diagnosticsPre.style.color = '#f38ba8'; + } else { + diagnosticsPre.style.color = '#f9e2af'; + } + } +} + +function tryEvaluate(ast: ProgramNode, exprName: string) { + let inputData: Record; + try { + inputData = JSON.parse(inputTextarea.value || '{}'); + } catch (e) { + outputPre.textContent = `Invalid JSON input: ${e instanceof Error ? e.message : String(e)}`; + outputPre.style.color = '#f38ba8'; + return; + } + + const { _sources, _tables, ...evalInput } = inputData as any; + const result = evaluate(ast, exprName, evalInput, _sources ?? undefined, _tables ?? undefined, PLUGINS); + + if (result.error) { + outputPre.textContent = `Error: ${result.error}`; + outputPre.style.color = '#f38ba8'; + } else { + outputPre.textContent = JSON.stringify(formatOutput(result.value), null, 2); + outputPre.style.color = '#a6e3a1'; + } +} + +/** Format output for display — converts money objects to readable strings. */ +function formatOutput(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (isMoneyValue(value)) return formatMoney(value); + if (Array.isArray(value)) return value.map(formatOutput); + if (typeof value === 'object') { + const result: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + result[k] = formatOutput(v); + } + return result; + } + return value; +} + +// Event listeners +editor.onDidChangeModelContent(() => { + clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(processCode, 300); +}); + +exprSelect.addEventListener('change', processCode); +exampleSelect.addEventListener('change', () => loadExample(exampleSelect.value)); +inputTextarea.addEventListener('input', () => { + clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(processCode, 300); +}); + +// Handle resize +window.addEventListener('resize', () => editor.layout()); + +// Initial processing +processCode(); diff --git a/playground/src/plugins/money.ts b/playground/src/plugins/money.ts new file mode 100644 index 0000000..803b20b --- /dev/null +++ b/playground/src/plugins/money.ts @@ -0,0 +1,233 @@ +import { AxiomPlugin } from '../lang/plugin'; +import { TypeSig, typeMoney, TYPE_NUMBER, TYPE_BOOL } from '../lang/types'; + +// Currency symbol → ISO code mapping +const SYMBOL_MAP: Record = { + '£': 'GBP', + '€': 'EUR', + '$': 'USD', + '¥': 'JPY', +}; + +// Reverse: ISO code → symbol (for display) +const SYMBOL_REVERSE: Record = { + GBP: '£', EUR: '€', USD: '$', JPY: '¥', +}; + +// Known ISO 4217 currency codes +const ISO_CODES = new Set([ + 'GBP', 'EUR', 'USD', 'JPY', 'AUD', 'CAD', 'CHF', 'CNY', + 'SEK', 'NOK', 'DKK', 'NZD', 'ZAR', 'SGD', 'HKD', 'INR', 'BRL', 'AED', +]); + +export interface MoneyValue { + _money: true; + amount: number; + currency: string; +} + +export function isMoneyValue(v: unknown): v is MoneyValue { + return v !== null && typeof v === 'object' && (v as MoneyValue)._money === true; +} + +/** Format a money value for display: £100.00 or USD 100.00 */ +export function formatMoney(v: MoneyValue): string { + const sym = SYMBOL_REVERSE[v.currency]; + const formatted = Number.isInteger(v.amount) ? v.amount.toFixed(0) : v.amount.toFixed(2); + return sym ? `${sym}${formatted}` : `${v.currency} ${formatted}`; +} + +/** Read the numeric portion of a money literal starting at `numStart`. */ +function readMoneyNumber(source: string, pos: number, prefixLen: number, currency: string) { + let i = pos + prefixLen; + let hasDecimal = false; + while (i < source.length) { + const ch = source[i]; + if (ch >= '0' && ch <= '9') { + i++; + } else if (ch === '.' && !hasDecimal && i + 1 < source.length && source[i + 1] >= '0' && source[i + 1] <= '9') { + hasDecimal = true; + i++; + } else { + break; + } + } + if (i === pos + prefixLen) return null; // no digits after prefix + + const amount = parseFloat(source.slice(pos + prefixLen, i)); + return { + tag: 'money', + value: source.slice(pos, i), + payload: { _money: true, amount, currency } as MoneyValue, + length: i - pos, + }; +} + +export const moneyPlugin: AxiomPlugin = { + name: 'money', + + lexer: { + tryTokenize(source: string, pos: number) { + const ch = source[pos]; + + // Symbol form: £123.45 + const currency = SYMBOL_MAP[ch]; + if (currency) { + return readMoneyNumber(source, pos, 1, currency); + } + + // ISO code form: GBP123.45 — 3 uppercase letters followed by a digit + if (ch >= 'A' && ch <= 'Z' && pos + 3 < source.length) { + const code = source.slice(pos, pos + 3); + if (ISO_CODES.has(code) && source[pos + 3] >= '0' && source[pos + 3] <= '9') { + return readMoneyNumber(source, pos, 3, code); + } + } + + return null; + }, + }, + + checker: { + inferLiteralType(tag: string, payload: unknown) { + if (tag === 'money') { + return typeMoney((payload as MoneyValue).currency); + } + return null; + }, + + checkBinaryOp(op: string, left: TypeSig, right: TypeSig) { + const leftIsMoney = left.name === 'money'; + const rightIsMoney = right.name === 'money'; + if (!leftIsMoney && !rightIsMoney) return null; // not our concern + + // money OP money + if (leftIsMoney && rightIsMoney) { + if (['+', '-'].includes(op)) { + if (left.params[0] !== right.params[0]) { + return { error: `Cannot ${op} money(${left.params[0]}) and money(${right.params[0]}) — currency mismatch` }; + } + return left; + } + if (op === '/') { + if (left.params[0] !== right.params[0]) { + return { error: `Cannot divide money(${left.params[0]}) by money(${right.params[0]}) — currency mismatch` }; + } + return TYPE_NUMBER; // ratio + } + if (['==', '!=', '<', '>', '<=', '>='].includes(op)) { + if (left.params[0] !== right.params[0]) { + return { error: `Cannot compare money(${left.params[0]}) and money(${right.params[0]}) — currency mismatch` }; + } + return TYPE_BOOL; + } + return { error: `Operator '${op}' is not supported between money values` }; + } + + // money OP number + if (leftIsMoney && right.name === 'number') { + if (op === '*' || op === '/') return left; + return { error: `Cannot '${op}' money(${left.params[0]}) and number — use * or / to scale money` }; + } + + // number OP money + if (left.name === 'number' && rightIsMoney) { + if (op === '*') return right; + return { error: `Cannot '${op}' number and money(${right.params[0]}) — use * to scale money` }; + } + + return null; + }, + + checkCall(name: string, argTypes: TypeSig[]) { + // round(money, number) → money + if (name === 'round' && argTypes.length === 2 && argTypes[0].name === 'money') { + return argTypes[0]; + } + // max/min(money, money) → money + if ((name === 'max' || name === 'min') && argTypes.length === 2 + && argTypes[0].name === 'money' && argTypes[1].name === 'money') { + return argTypes[0]; + } + // sum(list(money)) → money + if (name === 'sum' && argTypes.length === 1 + && argTypes[0].name === 'list' && argTypes[0].elementType?.name === 'money') { + return argTypes[0].elementType; + } + return null; + }, + }, + + evaluator: { + supportsOp(left: unknown, right: unknown, op: string) { + return isMoneyValue(left) || isMoneyValue(right); + }, + + evaluateOp(left: unknown, right: unknown, op: string): unknown { + if (isMoneyValue(left) && isMoneyValue(right)) { + if (left.currency !== right.currency) { + throw new Error(`Cannot ${op} ${formatMoney(left)} and ${formatMoney(right)} — currency mismatch`); + } + switch (op) { + case '+': return { _money: true, amount: left.amount + right.amount, currency: left.currency }; + case '-': return { _money: true, amount: left.amount - right.amount, currency: left.currency }; + case '/': return left.amount / right.amount; // ratio → number + case '==': return left.amount === right.amount; + case '!=': return left.amount !== right.amount; + case '<': return left.amount < right.amount; + case '>': return left.amount > right.amount; + case '<=': return left.amount <= right.amount; + case '>=': return left.amount >= right.amount; + default: throw new Error(`Unsupported operator '${op}' for money`); + } + } + + if (isMoneyValue(left) && typeof right === 'number') { + switch (op) { + case '*': return { _money: true, amount: left.amount * right, currency: left.currency }; + case '/': return right === 0 ? { _money: true, amount: 0, currency: left.currency } + : { _money: true, amount: left.amount / right, currency: left.currency }; + default: throw new Error(`Cannot '${op}' money and number`); + } + } + + if (typeof left === 'number' && isMoneyValue(right)) { + if (op === '*') return { _money: true, amount: left * right.amount, currency: right.currency }; + throw new Error(`Cannot '${op}' number and money`); + } + + throw new Error('Unsupported money operation'); + }, + + intrinsics: { + sum: (list: unknown) => { + if (!Array.isArray(list) || list.length === 0 || !isMoneyValue(list[0])) return undefined; + const currency = list[0].currency; + const total = list.reduce((acc: number, v: unknown) => acc + (isMoneyValue(v) ? v.amount : 0), 0); + return { _money: true, amount: total, currency } as MoneyValue; + }, + round: (n: unknown, decimals: unknown) => { + if (isMoneyValue(n)) { + const d = typeof decimals === 'number' ? decimals : 0; + const factor = Math.pow(10, d); + return { _money: true, amount: Math.round(n.amount * factor) / factor, currency: n.currency }; + } + return undefined; // fall through to built-in + }, + max: (...args: unknown[]) => { + if (args.length === 2 && isMoneyValue(args[0]) && isMoneyValue(args[1])) { + const a = args[0], b = args[1]; + return a.amount >= b.amount ? a : b; + } + return undefined; + }, + min: (...args: unknown[]) => { + if (args.length === 2 && isMoneyValue(args[0]) && isMoneyValue(args[1])) { + const a = args[0], b = args[1]; + return a.amount <= b.amount ? a : b; + } + return undefined; + }, + }, + }, +}; diff --git a/playground/src/utils/csv.ts b/playground/src/utils/csv.ts new file mode 100644 index 0000000..74efc04 --- /dev/null +++ b/playground/src/utils/csv.ts @@ -0,0 +1,61 @@ +/** + * Simple CSV parser for table artifacts. + * In production, the runtime (PHP) would handle this. + * This simulates artifact loading for the playground. + */ +export function parseCSV(text: string): Record[] { + const lines = text.trim().split('\n'); + if (lines.length < 2) return []; + + const headers = parseLine(lines[0]); + const rows: Record[] = []; + + for (let i = 1; i < lines.length; i++) { + const values = parseLine(lines[i]); + if (values.length === 0) continue; + const row: Record = {}; + for (let j = 0; j < headers.length; j++) { + const raw = values[j] ?? ''; + row[headers[j]] = coerce(raw); + } + rows.push(row); + } + + return rows; +} + +function parseLine(line: string): string[] { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"' && line[i + 1] === '"') { + current += '"'; + i++; + } else if (ch === '"') { + inQuotes = false; + } else { + current += ch; + } + } else if (ch === '"') { + inQuotes = true; + } else if (ch === ',') { + fields.push(current); + current = ''; + } else { + current += ch; + } + } + fields.push(current); + return fields; +} + +function coerce(value: string): string | number { + if (value === '') return ''; + const num = Number(value); + if (!isNaN(num) && value.trim() !== '') return num; + return value; +} diff --git a/playground/src/vite-env.d.ts b/playground/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/playground/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 0000000..6a83529 --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/playground/vite.config.ts b/playground/vite.config.ts new file mode 100644 index 0000000..c5257bb --- /dev/null +++ b/playground/vite.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + base: './', +}); From c0c67d2718b1c754b057700962d5c7251cedf8a6 Mon Sep 17 00:00:00 2001 From: Robert van Steen Date: Mon, 13 Apr 2026 15:57:20 +0200 Subject: [PATCH 2/3] Reshape repo around Axiom v1 runtime scaffold --- .github/PULL_REQUEST_TEMPLATE.md | 7 +- .github/copilot-instructions.md | 226 +- .github/workflows/tests.yaml | 57 +- .gitignore | 8 + CHANGELOG.md | 61 +- CONTRIBUTING.md | 280 +-- README.md | 546 +---- SECURITY.md | 66 +- axiom-canonical-program-format.md | 631 ++++++ axiom-php-implementation-plan.md | 662 ++++++ axiom-v1-spec.md | 1853 +++++++---------- composer.json | 28 +- legacy/README.md | 24 + legacy/composer.json | 56 + infection.json5 => legacy/infection.json5 | 0 legacy/phpstan.neon.dist | 7 + legacy/phpunit.xml.dist | 43 + legacy/pint.json | 6 + .../Exceptions/TransformValueException.php | 0 .../src}/Operators/BinaryOverloader.php | 0 .../src}/Operators/ComparisonOverloader.php | 0 .../src}/Operators/DefaultOverloader.php | 0 .../src}/Operators/HasOverloader.php | 0 .../src}/Operators/InOverloader.php | 0 .../src}/Operators/IntersectsOverloader.php | 0 .../src}/Operators/LogicalOverloader.php | 0 .../src}/Operators/NullOverloader.php | 0 .../src}/Operators/OperatorOverloader.php | 0 .../src}/Operators/OverloaderManager.php | 0 .../src}/Patterns/ExpressionMatcher.php | 0 .../src}/Patterns/LiteralMatcher.php | 0 .../src}/Patterns/PatternMatcher.php | 0 .../src}/Patterns/WildcardMatcher.php | 0 {src => legacy/src}/ResolutionInspector.php | 0 .../src}/Resolvers/BindableResolver.php | 0 .../src}/Resolvers/DelegatingResolver.php | 0 .../src}/Resolvers/InfixResolver.php | 0 .../src}/Resolvers/MatchResolver.php | 0 .../src}/Resolvers/MemberAccessResolver.php | 0 {src => legacy/src}/Resolvers/Resolver.php | 0 .../src}/Resolvers/StaticResolver.php | 0 .../src}/Resolvers/SymbolResolver.php | 0 .../src}/Resolvers/UnaryResolver.php | 0 .../src}/Resolvers/ValueResolver.php | 0 {src => legacy/src}/Source.php | 0 .../src}/Sources/ExpressionPattern.php | 0 .../src}/Sources/InfixExpression.php | 0 .../src}/Sources/LiteralPattern.php | 0 {src => legacy/src}/Sources/MatchArm.php | 0 .../src}/Sources/MatchExpression.php | 0 {src => legacy/src}/Sources/MatchPattern.php | 0 .../src}/Sources/MemberAccessSource.php | 0 {src => legacy/src}/Sources/StaticSource.php | 0 {src => legacy/src}/Sources/SymbolSource.php | 0 .../src}/Sources/TypeDefinition.php | 0 .../src}/Sources/UnaryExpression.php | 0 .../src}/Sources/WildcardPattern.php | 0 legacy/src/SymbolRegistry.php | 65 + {src => legacy/src}/Types/BooleanType.php | 0 {src => legacy/src}/Types/DictType.php | 0 {src => legacy/src}/Types/ListType.php | 0 legacy/src/Types/NumberType.php | 62 + {src => legacy/src}/Types/StringType.php | 0 legacy/src/Types/Type.php | 42 + .../tests}/DefaultOverloaderTest.php | 0 .../TransformValueExceptionTest.php | 0 .../tests}/KitchenSink/KitchenSinkTest.php | 0 .../tests}/LogicalOverloaderTest.php | 0 .../tests}/Operators/InOverloaderTest.php | 0 .../tests}/OverloaderManagerTest.php | 0 .../tests}/Patterns/ExpressionMatcherTest.php | 0 .../tests}/Patterns/LiteralMatcherTest.php | 0 .../tests}/Patterns/WildcardMatcherTest.php | 0 .../Resolvers/DelegatingResolverTest.php | 0 .../tests}/Resolvers/Fixtures/Dependency.php | 0 .../Fixtures/ResolverWithDependency.php | 0 .../Resolvers/Fixtures/SpyInspector.php | 0 .../tests}/Resolvers/InfixResolverTest.php | 0 .../tests}/Resolvers/MatchResolverTest.php | 0 .../Resolvers/MemberAccessResolverTest.php | 0 .../Resolvers/ResolutionInspectorTest.php | 0 .../tests}/Resolvers/StaticResolverTest.php | 0 .../tests}/Resolvers/SymbolResolverTest.php | 0 .../tests}/Resolvers/UnaryResolverTest.php | 0 .../tests}/Resolvers/ValueResolverTest.php | 0 legacy/tests/SymbolRegistryTest.php | 146 ++ .../tests}/Types/BooleanTypeTest.php | 0 .../tests}/Types/DictTypeTest.php | 0 .../tests}/Types/ListTypeTest.php | 0 .../tests}/Types/NumberTypeTest.php | 0 .../tests}/Types/StringTypeTest.php | 0 phpstan.neon.dist | 3 +- phpunit.xml.dist | 27 +- pint.json | 4 +- playground/src/examples/hospitality.axiom.ts | 104 +- playground/src/examples/insurance.axiom.ts | 20 +- playground/src/examples/landlords.axiom.ts | 21 +- playground/src/examples/money.axiom.ts | 25 +- playground/src/examples/tradespeople.axiom.ts | 34 +- playground/src/lang/checker.ts | 6 +- src/Artifacts/ArtifactRepository.php | 12 + src/Ast/Declaration.php | 7 + src/Ast/Node.php | 7 + src/Ast/Program.php | 18 + src/Conformance/ConformanceCase.php | 24 + src/Diagnostics/Diagnostic.php | 14 + src/Diagnostics/DiagnosticSeverity.php | 12 + src/Diagnostics/SourceLocation.php | 14 + src/Eval/Evaluator.php | 16 + src/Extensions/Extension.php | 10 + src/Input/InputValidator.php | 17 + src/Lexing/Lexer.php | 13 + src/Lexing/Token.php | 16 + src/Lexing/TokenType.php | 15 + src/Names/NameResolver.php | 13 + src/Parsing/Parser.php | 16 + src/Runtime/AnalyzedProgram.php | 22 + src/Runtime/Engine.php | 16 + src/Runtime/EvaluationRequest.php | 19 + src/Runtime/ParsedProgram.php | 22 + src/Runtime/ProgramBundle.php | 25 + src/Runtime/ResolvedProgram.php | 22 + src/Types/NumberType.php | 55 +- src/Types/Type.php | 34 +- src/Typing/TypeChecker.php | 13 + src/Values/DecimalValue.php | 19 + src/Values/Value.php | 10 + tests/Conformance/README.md | 11 + tests/ScaffoldTest.php | 65 + 129 files changed, 3360 insertions(+), 2317 deletions(-) create mode 100644 axiom-canonical-program-format.md create mode 100644 axiom-php-implementation-plan.md create mode 100644 legacy/README.md create mode 100644 legacy/composer.json rename infection.json5 => legacy/infection.json5 (100%) create mode 100644 legacy/phpstan.neon.dist create mode 100644 legacy/phpunit.xml.dist create mode 100644 legacy/pint.json rename {src => legacy/src}/Exceptions/TransformValueException.php (100%) rename {src => legacy/src}/Operators/BinaryOverloader.php (100%) rename {src => legacy/src}/Operators/ComparisonOverloader.php (100%) rename {src => legacy/src}/Operators/DefaultOverloader.php (100%) rename {src => legacy/src}/Operators/HasOverloader.php (100%) rename {src => legacy/src}/Operators/InOverloader.php (100%) rename {src => legacy/src}/Operators/IntersectsOverloader.php (100%) rename {src => legacy/src}/Operators/LogicalOverloader.php (100%) rename {src => legacy/src}/Operators/NullOverloader.php (100%) rename {src => legacy/src}/Operators/OperatorOverloader.php (100%) rename {src => legacy/src}/Operators/OverloaderManager.php (100%) rename {src => legacy/src}/Patterns/ExpressionMatcher.php (100%) rename {src => legacy/src}/Patterns/LiteralMatcher.php (100%) rename {src => legacy/src}/Patterns/PatternMatcher.php (100%) rename {src => legacy/src}/Patterns/WildcardMatcher.php (100%) rename {src => legacy/src}/ResolutionInspector.php (100%) rename {src => legacy/src}/Resolvers/BindableResolver.php (100%) rename {src => legacy/src}/Resolvers/DelegatingResolver.php (100%) rename {src => legacy/src}/Resolvers/InfixResolver.php (100%) rename {src => legacy/src}/Resolvers/MatchResolver.php (100%) rename {src => legacy/src}/Resolvers/MemberAccessResolver.php (100%) rename {src => legacy/src}/Resolvers/Resolver.php (100%) rename {src => legacy/src}/Resolvers/StaticResolver.php (100%) rename {src => legacy/src}/Resolvers/SymbolResolver.php (100%) rename {src => legacy/src}/Resolvers/UnaryResolver.php (100%) rename {src => legacy/src}/Resolvers/ValueResolver.php (100%) rename {src => legacy/src}/Source.php (100%) rename {src => legacy/src}/Sources/ExpressionPattern.php (100%) rename {src => legacy/src}/Sources/InfixExpression.php (100%) rename {src => legacy/src}/Sources/LiteralPattern.php (100%) rename {src => legacy/src}/Sources/MatchArm.php (100%) rename {src => legacy/src}/Sources/MatchExpression.php (100%) rename {src => legacy/src}/Sources/MatchPattern.php (100%) rename {src => legacy/src}/Sources/MemberAccessSource.php (100%) rename {src => legacy/src}/Sources/StaticSource.php (100%) rename {src => legacy/src}/Sources/SymbolSource.php (100%) rename {src => legacy/src}/Sources/TypeDefinition.php (100%) rename {src => legacy/src}/Sources/UnaryExpression.php (100%) rename {src => legacy/src}/Sources/WildcardPattern.php (100%) create mode 100644 legacy/src/SymbolRegistry.php rename {src => legacy/src}/Types/BooleanType.php (100%) rename {src => legacy/src}/Types/DictType.php (100%) rename {src => legacy/src}/Types/ListType.php (100%) create mode 100644 legacy/src/Types/NumberType.php rename {src => legacy/src}/Types/StringType.php (100%) create mode 100644 legacy/src/Types/Type.php rename {tests => legacy/tests}/DefaultOverloaderTest.php (100%) rename {tests => legacy/tests}/Exceptions/TransformValueExceptionTest.php (100%) rename {tests => legacy/tests}/KitchenSink/KitchenSinkTest.php (100%) rename {tests => legacy/tests}/LogicalOverloaderTest.php (100%) rename {tests => legacy/tests}/Operators/InOverloaderTest.php (100%) rename {tests => legacy/tests}/OverloaderManagerTest.php (100%) rename {tests => legacy/tests}/Patterns/ExpressionMatcherTest.php (100%) rename {tests => legacy/tests}/Patterns/LiteralMatcherTest.php (100%) rename {tests => legacy/tests}/Patterns/WildcardMatcherTest.php (100%) rename {tests => legacy/tests}/Resolvers/DelegatingResolverTest.php (100%) rename {tests => legacy/tests}/Resolvers/Fixtures/Dependency.php (100%) rename {tests => legacy/tests}/Resolvers/Fixtures/ResolverWithDependency.php (100%) rename {tests => legacy/tests}/Resolvers/Fixtures/SpyInspector.php (100%) rename {tests => legacy/tests}/Resolvers/InfixResolverTest.php (100%) rename {tests => legacy/tests}/Resolvers/MatchResolverTest.php (100%) rename {tests => legacy/tests}/Resolvers/MemberAccessResolverTest.php (100%) rename {tests => legacy/tests}/Resolvers/ResolutionInspectorTest.php (100%) rename {tests => legacy/tests}/Resolvers/StaticResolverTest.php (100%) rename {tests => legacy/tests}/Resolvers/SymbolResolverTest.php (100%) rename {tests => legacy/tests}/Resolvers/UnaryResolverTest.php (100%) rename {tests => legacy/tests}/Resolvers/ValueResolverTest.php (100%) create mode 100644 legacy/tests/SymbolRegistryTest.php rename {tests => legacy/tests}/Types/BooleanTypeTest.php (100%) rename {tests => legacy/tests}/Types/DictTypeTest.php (100%) rename {tests => legacy/tests}/Types/ListTypeTest.php (100%) rename {tests => legacy/tests}/Types/NumberTypeTest.php (100%) rename {tests => legacy/tests}/Types/StringTypeTest.php (100%) create mode 100644 src/Artifacts/ArtifactRepository.php create mode 100644 src/Ast/Declaration.php create mode 100644 src/Ast/Node.php create mode 100644 src/Ast/Program.php create mode 100644 src/Conformance/ConformanceCase.php create mode 100644 src/Diagnostics/Diagnostic.php create mode 100644 src/Diagnostics/DiagnosticSeverity.php create mode 100644 src/Diagnostics/SourceLocation.php create mode 100644 src/Eval/Evaluator.php create mode 100644 src/Extensions/Extension.php create mode 100644 src/Input/InputValidator.php create mode 100644 src/Lexing/Lexer.php create mode 100644 src/Lexing/Token.php create mode 100644 src/Lexing/TokenType.php create mode 100644 src/Names/NameResolver.php create mode 100644 src/Parsing/Parser.php create mode 100644 src/Runtime/AnalyzedProgram.php create mode 100644 src/Runtime/Engine.php create mode 100644 src/Runtime/EvaluationRequest.php create mode 100644 src/Runtime/ParsedProgram.php create mode 100644 src/Runtime/ProgramBundle.php create mode 100644 src/Runtime/ResolvedProgram.php create mode 100644 src/Typing/TypeChecker.php create mode 100644 src/Values/DecimalValue.php create mode 100644 src/Values/Value.php create mode 100644 tests/Conformance/README.md create mode 100644 tests/ScaffoldTest.php diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 34ad9ef..b5b26b4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -32,11 +32,9 @@ Fixes # -- [ ] All existing tests pass (`composer test`) +- [ ] Root PHP validation passes (`composer test`) +- [ ] Playground build passes (`cd playground && npm run build`) when relevant - [ ] Added new tests for the changes -- [ ] Static analysis passes (`composer test:types`) -- [ ] Code coverage remains at 100% -- [ ] Mutation testing passes (`composer test:infection`) ## Code Quality Checklist @@ -47,6 +45,7 @@ Fixes # - [ ] My changes generate no new warnings or errors - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes +- [ ] I have noted whether the change affects the root implementation, the playground, the spec, or `legacy/` ## Documentation diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8d0e265..83564c9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,224 +1,40 @@ -# Copilot Agent Instructions for Axiom Library +# Copilot Agent Instructions for Axiom ## Repository Overview -This is a proprietary PHP library for **data transformation, type validation, and expression evaluation**. The library provides a flexible framework for defining data schemas, transforming values, and evaluating complex expressions with type safety using functional programming principles. +This repository is in transition toward a spec-driven Axiom v1 DSL project. -**Repository Stats:** -- **Language:** PHP (100%) -- **Size:** ~30 source files, ~20 test files -- **Type:** Library package (`gosuperscript/axiom`) -- **Architecture:** Functional programming with monadic error handling +There are three distinct surfaces: -**Key Features:** -- Type system for numbers, strings, booleans, lists, and dictionaries -- Expression evaluation with infix and unary expressions -- Pluggable resolver pattern for different data sources -- Symbol registry for named value resolution -- Operator overloading system -- Built on Result and Option monads for error handling +- `axiom-v1-spec.md`: the primary language source of truth +- `src/` and `tests/`: the fresh root PHP implementation surface +- `playground/`: the experimental TypeScript playground -## Build and Validation Instructions +The old PHP library has been archived under `legacy/`. Do not treat `legacy/` +as the active implementation unless a task explicitly targets it. -### Environment Requirements -- **PHP:** 8.4+ (strictly enforced) -- **Extensions:** ext-intl (required) -- **Docker:** Recommended for development (8.4-cli-alpine image) +## Working Rules -### Setup Commands -**ALWAYS run these commands in the specified order:** +- Prefer the current Axiom v1 specification over legacy implementation behavior. +- Keep the root PHP implementation small and deliberate. +- Treat the playground as a validation tool, not as the language definition. +- Avoid reintroducing old library abstractions just because they exist in + `legacy/`. -1. **Install Dependencies:** - ```bash - composer install - ``` - - **Precondition:** PHP 8.4+ must be available - - **Time:** ~30-60 seconds - - **Note:** May require GitHub token for private repositories +## Validation -2. **Docker Setup (if PHP 8.4 unavailable):** - ```bash - docker compose build - docker compose run --rm php composer install - ``` - - **Time:** 2-5 minutes for initial build - - **Note:** Network connectivity required for base image - -### Testing Commands -**100% code coverage is required for all new code.** +For root PHP changes: ```bash -# Run all tests (recommended) composer test - -# Individual test suites -composer test:unit # PHPUnit tests (requires 100% coverage) -composer test:types # PHPStan static analysis (level max) -composer test:infection # Mutation testing (100% MSI required) ``` -**Test Execution Times:** -- Unit tests: ~10-30 seconds -- Static analysis: ~5-15 seconds -- Mutation testing: ~1-3 minutes - -### Code Quality Tools - -1. **PHPStan (Static Analysis):** - ```bash - vendor/bin/phpstan analyse - ``` - - Level: max (strictest) - - **Always pass** before submitting changes - -2. **Laravel Pint (Code Formatting):** - ```bash - vendor/bin/pint - ``` - - Preset: PER (PHP Evolving Recommendations) - - Auto-fixes code style issues +For playground changes: -3. **Infection (Mutation Testing):** - ```bash - vendor/bin/infection --threads=max --show-mutations - ``` - - Minimum MSI: 100% (all mutants must be killed) - - **Critical:** Tests quality validation - -### Docker Environment ```bash -# Build environment -docker compose build - -# Run commands in container -docker compose run --rm php composer install -docker compose run --rm php composer test -docker compose run --rm php vendor/bin/phpstan analyse -``` - -## Project Layout and Architecture - -### Core Architecture Patterns -- **Strategy Pattern:** Different resolvers for different source types -- **Chain of Responsibility:** DelegatingResolver chains multiple resolvers -- **Factory Pattern:** Type system creates appropriate transformations -- **Functional Programming:** Result and Option monads throughout - -### Directory Structure +cd playground +npm run build ``` -src/ -├── Exceptions/ # Custom exception classes -├── Operators/ # Operator overloading system -├── Resolvers/ # Source resolution strategies -├── Sources/ # Data source definitions -├── Types/ # Type validation and transformation -├── Source.php # Base source interface -└── SymbolRegistry.php # Named value registry - -tests/ -├── KitchenSink/ # Integration tests -├── Operators/ # Operator tests -├── Resolvers/ # Resolver tests -├── Types/ # Type system tests -└── *Test.php # Unit tests -``` - -### Key Source Files - -**Core Interfaces:** -- `src/Source.php` - Base interface for all data sources -- `src/Resolvers/Resolver.php` - Resolver interface template -- `src/Types/Type.php` - Type transformation interface - -**Main Implementation:** -- `src/Resolvers/DelegatingResolver.php` - Main resolver chain -- `src/SymbolRegistry.php` - Symbol management -- `src/Types/NumberType.php` - Example type implementation - -### Configuration Files - -**Build Configuration:** -- `composer.json` - Dependencies and scripts -- `phpunit.xml.dist` - Test configuration -- `phpstan.neon.dist` - Static analysis rules -- `infection.json5` - Mutation testing config -- `pint.json` - Code style rules - -**Docker Configuration:** -- `Dockerfile` - PHP 8.4 Alpine development environment -- `docker-compose.yaml` - Development services - -### GitHub Workflows -Located in `.github/workflows/tests.yaml`: -- **Test Job:** Runs on PHP 8.4 with matrix for prefer-lowest/prefer-stable -- **Types Job:** Static analysis validation -- **Timeout:** 5 minutes per job -- **Extensions:** Includes intl, bcmath, and testing extensions - -### Dependencies - -**Production:** -- `azjezz/psl` - PHP Standard Library utilities -- `brick/math` - Arbitrary precision mathematics -- `gosuperscript/monads` - Result/Option monad implementation -- `illuminate/container` - Dependency injection container -- `sebastian/exporter` - Value exporting utilities - -**Development:** -- `phpunit/phpunit` (v12.0+) - Testing framework -- `phpstan/phpstan` (v2.1+) - Static analysis -- `infection/infection` - Mutation testing -- `laravel/pint` - Code formatting - -### Validation Pipeline - -**Local Development Checklist:** -1. Run `composer test:types` (must pass) -2. Run `composer test:unit` (100% coverage required) -3. Run `composer test:infection` (100% MSI required) -4. Optionally run `vendor/bin/pint` for formatting - -**CI/CD Validation:** -- All tests run on PHP 8.4 only -- Matrix testing with prefer-lowest and prefer-stable -- Parallel execution of unit tests and static analysis -- **No deployment** - library package only - -### Common Patterns and Usage - -**Creating New Types:** -- Implement `Type` interface with transform(), compare(), format() -- Return `Result, Throwable>` from transform() -- Use functional approach with Result monads - -**Creating New Resolvers:** -- Implement `Resolver` interface -- Add static supports() method for source type checking -- Register in DelegatingResolver constructor array - -**Error Handling:** -- All operations return Result types (Ok/Err) -- No exceptions for normal control flow -- Option types handle null/empty values safely - -### Testing Requirements - -**Code Coverage:** 100% line coverage mandatory -**Mutation Testing:** 100% Mutation Score Indicator required -**Static Analysis:** PHPStan level max with no errors - -**Test Structure:** -- Use PHPUnit 12+ attributes (`#[Test]`, `#[CoversClass]`) -- Integration tests in `tests/KitchenSink/` -- Unit tests mirror source structure -- Use `#[CoversNothing]` for integration tests - ---- - -## Agent Instructions - -**Trust these instructions** and only search/explore when information is incomplete or incorrect. This repository requires PHP 8.4 and has strict quality requirements - always validate changes with the full test suite before submitting. -**For type system changes:** Focus on functional programming patterns and monadic error handling. -**For resolver changes:** Follow the chain of responsibility pattern and ensure proper source type checking. -**For new features:** Maintain 100% test coverage and mutation score requirements. \ No newline at end of file +Run only the relevant validations for the surfaces you change, and say what you +did not run. diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 494b64c..d7c3174 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,18 +1,14 @@ -name: Tests +name: Validation on: push: + pull_request: jobs: - test: + php: runs-on: ubuntu-latest timeout-minutes: 5 - strategy: - matrix: - php: [8.4] - stability: [prefer-lowest, prefer-stable] - - name: P${{ matrix.php }} - ${{ matrix.stability }} + name: PHP steps: - name: Checkout code @@ -21,9 +17,9 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php }} + php-version: 8.4 extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo - coverage: pcov + coverage: none - name: Setup problem matchers run: | @@ -31,44 +27,29 @@ jobs: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - name: Install dependencies - run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - - name: List Installed Dependencies - run: composer show -D - - - name: Execute unit tests - run: composer test:unit + run: composer install --prefer-dist --no-interaction - - name: Execute mutation tests - run: composer test:infection + - name: Execute PHP validation + run: composer test - types: + playground: runs-on: ubuntu-latest timeout-minutes: 5 - strategy: - matrix: - php: [8.4] - - name: Static analysis + name: Playground steps: - name: Checkout code uses: actions/checkout@v4 - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - name: Setup Node + uses: actions/setup-node@v4 with: - php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo - coverage: none - - - name: Setup problem matchers - run: | - echo "::add-matcher::${{ runner.tool_cache }}/php.json" - echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + node-version: 22 - name: Install dependencies - run: composer install --prefer-dist --no-interaction + working-directory: playground + run: npm install - - name: Execute type tests - run: composer test:types + - name: Build playground + working-directory: playground + run: npm run build diff --git a/.gitignore b/.gitignore index c706bbd..463c694 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,13 @@ infection.log playground/dist playground/node_modules +# Legacy archive +legacy/vendor +legacy/build +legacy/.phpunit.cache +legacy/.phpunit.result.cache +legacy/composer.lock +legacy/infection.log + # Claude Code .claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1531e2d..0655526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,59 +1,36 @@ # Changelog -All notable changes to this project will be documented in this file. +All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +and the project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +for released artifacts. ## [Unreleased] ### Added -- Initial open source release -- MIT License -- Contributing guidelines -- Security policy -- Comprehensive documentation + +- Axiom v1 language specification +- PHP implementation plan for a future reference runtime ### Changed -- Changed license from proprietary to MIT + +- repository documentation now reflects the current spec-first project state +- playground examples were realigned with the rewritten Axiom v1 direction +- the pre-v1 PHP runtime was archived under `legacy/` +- the root `src/` and `tests/` surfaces were reset for a fresh PHP + implementation ## [1.0.0] - Initial Release ### Added -- Type system for data validation and transformation - - NumberType for numeric coercion - - StringType for string conversion - - BooleanType for boolean validation - - ListType for array/list validation - - DictType for dictionary/map validation -- Expression evaluation system - - InfixExpression for binary operations - - UnaryExpression for unary operations - - Operator overloading support -- Source system - - StaticSource for direct values - - SymbolSource for named references with namespace support - - TypeDefinition for type-aware transformations -- Resolver pattern implementation - - DelegatingResolver for chaining resolvers - - StaticResolver for static value resolution - - ValueResolver for type coercion - - InfixResolver for expression evaluation - - SymbolResolver for symbol lookup -- SymbolRegistry for managing named values with namespace support -- Functional programming approach - - Result monad for error handling - - Option monad for null handling -- Comprehensive test suite - - 100% code coverage requirement - - PHPStan level max static analysis - - Mutation testing with Infection - -### Architecture -- Strategy pattern for resolvers -- Chain of responsibility for delegating resolvers -- Factory pattern for type creation -- Functional programming with monadic error handling + +- type system for data validation and transformation +- expression evaluation system +- source system +- resolver-based architecture +- symbol registry support +- comprehensive PHP test suite [Unreleased]: https://github.com/gosuperscript/axiom/compare/v1.0.0...HEAD [1.0.0]: https://github.com/gosuperscript/axiom/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16a2457..4d29609 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,253 +1,101 @@ -# Contributing to Axiom Library +# Contributing to Axiom -Thank you for your interest in contributing to the Axiom Library! We welcome contributions from the community. +This repository is currently transitioning from an older PHP expression library +toward a spec-driven Axiom v1 DSL project. Contributions are welcome, but +changes should be grounded in the current language direction rather than the +archived library surface alone. -## How Can I Contribute? +## Before You Start -### Reporting Bugs +Read the current project anchors first: -Before creating bug reports, please check existing issues as you might find that you don't need to create one. When you create a bug report, please include as many details as possible: +- [Axiom v1 Specification](./axiom-v1-spec.md) +- [PHP Implementation Plan](./axiom-php-implementation-plan.md) +- [README](./README.md) -* **Use a clear and descriptive title** -* **Describe the exact steps to reproduce the problem** -* **Provide specific examples to demonstrate the steps** -* **Describe the behavior you observed and what you expected** -* **Include PHP version and environment details** +If a proposed change conflicts with the current spec or with the planned PHP +direction, resolve that at the documentation level first. -### Suggesting Enhancements +## Current Contribution Priorities -Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include: +- improve the Axiom v1 specification +- keep examples aligned with the specification +- tighten playground behavior and diagnostics where it helps validate the spec +- prepare the repository for a clean PHP implementation +- add conformance-style tests and fixtures -* **Use a clear and descriptive title** -* **Provide a detailed description of the suggested enhancement** -* **Explain why this enhancement would be useful** -* **List any similar features in other libraries** +## Repository Areas -### Pull Requests +### Spec and Documentation -* Fill in the required template -* Follow the PHP coding style (PER/PSR-12) -* Include tests for new functionality -* Ensure all tests pass -* Update documentation as needed -* Write clear, descriptive commit messages +Changes to the language should update the relevant documentation in the same +pull request. The specification should not drift away from the examples or from +the implementation plan. -## Development Setup - -1. **Fork and clone the repository** - ```bash - git clone https://github.com/your-username/axiom.git - cd axiom - ``` - -2. **Install dependencies** - ```bash - composer install - ``` +### Playground -3. **Run tests to ensure everything works** - ```bash - composer test - ``` +The playground is exploratory. It is useful for validating syntax and semantics, +but it is not the canonical implementation target. Keep playground changes +clearly aligned with the spec and avoid using the playground as the de facto +language definition. -### Docker Development +### Existing PHP Codebase -If you don't have PHP 8.4 installed locally, you can use Docker: +The archived code now lives in [`legacy/`](./legacy). Treat it as groundwork +and reference material, not as the active Axiom v1 runtime. -```bash -docker compose build -docker compose run --rm php composer install -docker compose run --rm php composer test -``` +### Fresh Root PHP Implementation -## Development Workflow +The active PHP implementation surface now starts in [`src/`](./src) and +[`tests/`](./tests). Keep that surface intentionally small and aligned with the +current specification. -### Code Style +## Development Setup -We use Laravel Pint for code formatting: +### PHP Codebase ```bash -vendor/bin/pint +composer install +composer test ``` -This will automatically fix code style issues according to the PER (PHP Evolving Recommendations) preset. - -### Testing - -The project requires **100% code coverage** for all new code. We use three types of testing: - -1. **Unit Tests** (PHPUnit) - ```bash - composer test:unit - ``` - * All new code must have corresponding tests - * Tests must achieve 100% line coverage - * Use PHPUnit 12+ attributes (`#[Test]`, `#[CoversClass]`) - -2. **Static Analysis** (PHPStan) - ```bash - composer test:types - ``` - * Analysis level: max (strictest) - * All code must pass without errors - -3. **Mutation Testing** (Infection) - ```bash - composer test:infection - ``` - * Required Mutation Score Indicator (MSI): 100% - * Ensures test quality and effectiveness - -### Running All Tests +### TypeScript Playground ```bash -composer test +cd playground +npm install +npm run build ``` -This runs all three test suites in sequence. - -## Coding Guidelines - -### PHP Version - -* **Minimum PHP version:** 8.4 -* Use modern PHP features (readonly properties, enums, etc.) -* Follow strict typing (`declare(strict_types=1)`) +## Change Expectations -### Architecture Principles +- keep changes focused +- update docs when semantics or repository structure changes +- prefer explicit, reviewable designs over clever shortcuts +- do not widen the language surface casually +- add or update tests when changing behavior -1. **Functional Programming** - * Use Result and Option monads for error handling - * Avoid exceptions for control flow - * Prefer immutability +## Pull Requests -2. **Type Safety** - * All methods must have type declarations - * Use PHPStan level max compliance - * Return explicit Result/Option types +- use a clear title and description +- explain whether the change affects the spec, the playground, the PHP codebase, + or more than one of them +- call out any intentional divergence from existing behavior +- include follow-up work if the change is deliberately partial -3. **Design Patterns** - * Strategy Pattern for resolvers - * Chain of Responsibility for delegating resolvers - * Factory Pattern for type creation +## Testing -### Code Structure +For PHP changes, run: -* One class per file -* Follow PSR-4 autoloading -* Keep classes focused and single-purpose -* Write self-documenting code - -### Documentation - -* Update README.md if adding new features -* Include PHPDoc blocks for complex methods -* Add code examples for new functionality -* Keep documentation clear and concise - -## Testing Best Practices - -### Writing Tests - -```php -doSomething(); - - // Assert - self::assertTrue($result->isOk()); - } -} -``` - -### Test Organization - -* Unit tests mirror source structure -* Integration tests go in `tests/KitchenSink/` -* Use `#[CoversClass]` for unit tests -* Use `#[CoversNothing]` for integration tests - -## Commit Message Guidelines - -* Use present tense ("Add feature" not "Added feature") -* Use imperative mood ("Move cursor to..." not "Moves cursor to...") -* Limit first line to 72 characters -* Reference issues and pull requests when relevant - -Examples: +```bash +composer test ``` -Add support for custom operators in expressions -Fix null handling in StringType coercion +For playground changes, run: -Update documentation for SymbolRegistry namespaces +```bash +cd playground +npm run build ``` -## Pull Request Process - -1. **Create a feature branch** - ```bash - git checkout -b feature/your-feature-name - ``` - -2. **Make your changes** - * Write code - * Add tests - * Update documentation - -3. **Ensure quality** - ```bash - vendor/bin/pint # Format code - composer test:types # Check static analysis - composer test:unit # Run unit tests - composer test:infection # Check mutation testing - ``` - -4. **Commit and push** - ```bash - git add . - git commit -m "Your descriptive message" - git push origin feature/your-feature-name - ``` - -5. **Create Pull Request** - * Fill out the PR template completely - * Link related issues - * Await code review - -6. **Address feedback** - * Make requested changes - * Push updates to the same branch - * Respond to review comments - -## Additional Resources - -* [PHP Fig - PSR Standards](https://www.php-fig.org/psr/) -* [PHPStan Documentation](https://phpstan.org/) -* [Infection Mutation Testing](https://infection.github.io/) -* [azjezz/psl Library](https://github.com/azjezz/psl) - Our standard library - -## Questions? - -Feel free to open an issue for: -* Questions about contributing -* Clarifications on architecture -* Help with development setup - -Thank you for contributing! 🎉 +If you do not run a relevant validation step, say so in the pull request. diff --git a/README.md b/README.md index cbb46ff..7f3824b 100644 --- a/README.md +++ b/README.md @@ -1,496 +1,82 @@ -# Axiom Library - -A powerful PHP library for data transformation, type validation, and expression evaluation. This library provides a flexible framework for defining data schemas, transforming values, and evaluating complex expressions with type safety. - -## Features - -- **Type System**: Robust type validation and transformation for numbers, strings, booleans, lists, and dictionaries -- **Expression Evaluation**: Support for infix expressions with custom operators -- **Match Expressions**: Unified conditional logic — if/then/else, dispatch tables, and cond-style matching -- **Compiled Expressions**: Turn a source tree into a callable you invoke with inputs -- **Resolver Pattern**: Pluggable resolver system for different data sources -- **Operator Overloading**: Extensible operator system for custom evaluation logic -- **Monadic Error Handling**: Built on functional programming principles using Result and Option types - -## Requirements - -- PHP 8.4 or higher -- ext-intl extension - -## Installation - -```bash -composer require gosuperscript/axiom -``` - -## Quick Start - -### Expressions as callables - -The top-level API is `Expression`: wrap a `Source` tree with the resolver stack you want, then invoke it with inputs like a function: - -```php - StaticResolver::class, - SymbolSource::class => SymbolResolver::class, - InfixExpression::class => InfixResolver::class, -]); -$resolver->instance(OperatorOverloader::class, new DefaultOverloader()); - -// area = PI * radius * radius -$source = new InfixExpression( - left: new SymbolSource('PI'), - operator: '*', - right: new InfixExpression( - left: new SymbolSource('radius'), - operator: '*', - right: new SymbolSource('radius'), - ), -); - -$area = new Expression( - source: $source, - resolver: $resolver, - definitions: new Definitions(['PI' => new StaticSource(3.14159)]), -); - -$area->parameters(); // ['radius'] - -$area(['radius' => 5])->unwrap()->unwrap(); // ~78.54 -$area(['radius' => 10])->unwrap()->unwrap(); // ~314.16 -``` - -The key idea: the expression's inputs are its **parameters**, passed at the call site. - -### Basic Type Transformation - -```php - StaticResolver::class, - TypeDefinition::class => ValueResolver::class, -]); - -$source = new TypeDefinition( - type: new NumberType(), - source: new StaticSource('42'), -); - -$expression = new Expression($source, $resolver); - -$expression()->unwrap()->unwrap(); // 42 (as integer) -``` - -### Inputs, Definitions, and Namespaces - -Inputs are **bindings** — passed at the call site. Stable named expressions (constants, named sub-expressions) are **definitions** — bound once when the `Expression` is constructed. Both support flat names and dotted namespaces. - -```php -use Superscript\Axiom\Definitions; -use Superscript\Axiom\Expression; -use Superscript\Axiom\Sources\StaticSource; -use Superscript\Axiom\Sources\SymbolSource; - -$expression = new Expression( - source: /* ... */, - resolver: $resolver, - definitions: new Definitions([ - // Global scope - 'version' => new StaticSource('1.0.0'), - // Namespaced scope - 'math' => [ - 'pi' => new StaticSource(3.14159), - 'e' => new StaticSource(2.71828), - ], - ]), -); - -// Flat and namespaced inputs -$expression([ - 'tier' => 'small', - 'quote' => [ - 'claims' => 3, - 'turnover' => 600000, - ], -]); -``` - -`SymbolSource` looks up by name + optional namespace: - -```php -new SymbolSource('pi', 'math'); // -> math.pi -new SymbolSource('claims', 'quote'); // -> quote.claims -new SymbolSource('version'); // -> version (global) -``` - -**Bindings shadow definitions.** A binding with a `null` value is still a real binding — it intentionally shadows any definition of the same name. - -### Match Expressions - -`MatchExpression` provides a unified way to express conditionals, dispatch tables, and cond-style matching. A match expression has a **subject** and an ordered list of **arms**. Each arm pairs a pattern with a result expression. The first matching arm wins. - -**Patterns:** - -- **LiteralPattern**: Matches via strict equality (`===`) -- **WildcardPattern**: Always matches (the default/catch-all arm) -- **ExpressionPattern**: Wraps a `Source` — resolves it and compares to the subject - -**If/then/else:** - -```php -// if quote.claims > 2 then 100 * 0.25 else 0 -new MatchExpression( - subject: new StaticSource(true), - arms: [ - new MatchArm( - new ExpressionPattern( - new InfixExpression(new SymbolSource('claims', 'quote'), '>', new StaticSource(2)), - ), - new InfixExpression(new StaticSource(100), '*', new StaticSource(0.25)), - ), - new MatchArm(new WildcardPattern(), new StaticSource(0)), - ], -); -``` - -**Dispatch table:** - -```php -// match tier { "micro" => 1.3, "small" => 1.1, _ => 1.0 } -new MatchExpression( - subject: new SymbolSource('tier'), - arms: [ - new MatchArm(new LiteralPattern('micro'), new StaticSource(1.3)), - new MatchArm(new LiteralPattern('small'), new StaticSource(1.1)), - new MatchArm(new WildcardPattern(), new StaticSource(1.0)), - ], -); -``` - -**Extensible pattern matching:** The `MatchResolver` delegates pattern evaluation to a registry of `PatternMatcher` implementations. Extension packages can register their own pattern types (e.g. `IntervalPattern` from `axiom-interval`) without modifying core axiom: - -```php -$matchers = [ - new WildcardMatcher(), - new LiteralMatcher(), - new ExpressionMatcher($resolver), - // Add custom matchers from extension packages here -]; - -$resolver->instance(MatchResolver::class, new MatchResolver($resolver, $matchers)); -``` - -## Core Concepts - -### Types - -The library provides several built-in types for data validation and coercion: - -#### NumberType -Validates and coerces values to numeric types (int/float): -- Numeric strings: `"42"` → `42` -- Percentage strings: `"50%"` → `0.5` -- Numbers: `42.5` → `42.5` - -#### StringType -Validates and coerces values to strings: -- Numbers: `42` → `"42"` -- Stringable objects: converted to string representation -- Special handling for null and empty values - -#### BooleanType -Validates and coerces values to boolean: -- Truthy/falsy evaluation -- String representations: `"true"`, `"false"` - -#### ListType and DictType -For collections and associative arrays with nested type validation. - -### Type API: Assert vs Coerce - -The `Type` interface provides two methods for value processing, following the [@azjezz/psl](https://github.com/azjezz/psl) pattern: - -- **`assert(T $value): Result>`** - Validates that a value is already of the correct type -- **`coerce(mixed $value): Result>`** - Attempts to convert a value from any type to the target type - -**When to use:** -- Use `assert()` when you expect a value to already be the correct type and want strict validation -- Use `coerce()` when you want to transform values from various input types (permissive conversion) - -**Example:** -```php -$numberType = new NumberType(); - -$numberType->assert(42); // Ok(Some(42)) -$numberType->assert('42'); // Err(TransformValueException) - -$numberType->coerce(42); // Ok(Some(42)) -$numberType->coerce('42'); // Ok(Some(42)) -$numberType->coerce('45%'); // Ok(Some(0.45)) -``` - -Both methods return `Result, Throwable>` where: -- `Ok(Some(value))` - successful validation/coercion with a value -- `Ok(None())` - successful but no value (e.g., empty strings) -- `Err(exception)` - failed validation/coercion - -### Sources - -Sources represent different ways to provide data: - -- **StaticSource**: Direct values -- **SymbolSource**: Named references resolved from the context's bindings or definitions -- **TypeDefinition**: Combines a type with a source for validation and coercion -- **InfixExpression**: Mathematical/logical expressions -- **UnaryExpression**: Single-operand expressions -- **MatchExpression**: Conditional matching with ordered arms -- **MemberAccessSource**: Chained property/array-key access - -### Resolvers - -Resolvers handle the evaluation of sources. They are **stateless** — all per-call state (bindings, definitions, the inspector, and the symbol memo) lives on a `Context` threaded through `resolve(Source, Context)`: - -- **StaticResolver**: Resolves static values -- **ValueResolver**: Applies type coercion using the `coerce()` method -- **InfixResolver**: Evaluates binary expressions -- **UnaryResolver**: Evaluates unary expressions -- **SymbolResolver**: Looks up symbols from bindings (first) then definitions (with per-context memoization) -- **MemberAccessResolver**: Evaluates property/array-key access -- **MatchResolver**: Evaluates match expressions with extensible pattern matching -- **DelegatingResolver**: Chains multiple resolvers together - -### Context - -`Context` carries everything a single call needs: - -```php -use Superscript\Axiom\Bindings; -use Superscript\Axiom\Context; -use Superscript\Axiom\Definitions; - -$context = new Context( - bindings: new Bindings(['radius' => 5]), - definitions: new Definitions(['PI' => new StaticSource(3.14159)]), - inspector: $inspector, // optional -); - -$resolver->resolve($source, $context); -``` - -`Expression::call()` / `Expression::__invoke()` build the `Context` for you from the bindings you pass. - -### Operators - -The library supports various operators through the overloader system: - -- **Binary**: `+`, `-`, `*`, `/`, `%`, `**` -- **Comparison**: `==`, `!=`, `<`, `<=`, `>`, `>=` -- **Logical**: `&&`, `||` -- **Special**: `has`, `in`, `intersects` - -### Resolution Inspector - -The `ResolutionInspector` interface provides a zero-overhead observability primitive for resolution. Resolvers accept the inspector via the `Context` and annotate metadata about their work. When no inspector is present on the context, resolvers skip annotation entirely via null-safe calls. - -**Interface:** - -```php -interface ResolutionInspector -{ - public function annotate(string $key, mixed $value): void; -} -``` - -**Built-in annotations from first-party resolvers:** - -| Resolver | Annotations | -|----------|-------------| -| `StaticResolver` | `label`: `"static(int)"`, `"static(string)"`, etc. | -| `ValueResolver` | `label`: type class name (e.g. `"NumberType"`); `coercion`: type change (e.g. `"string -> int"`) | -| `InfixResolver` | `label`: operator (e.g. `"+"`, `"&&"`); `left`, `right`, `result` | -| `UnaryResolver` | `label`: operator (e.g. `"!"`, `"-"`); `result` | -| `SymbolResolver` | `label`: symbol name (e.g. `"A"`, `"math.pi"`); `memo`: `"hit"`/`"miss"`; `result` | -| `MatchResolver` | `label`: `"match"`; `subject`: resolved subject value; `matched_arm`: index of matched arm; `result`: final value | - -**Usage:** - -```php -use Superscript\Axiom\ResolutionInspector; - -final class ResolutionContext implements ResolutionInspector -{ - private array $annotations = []; - - public function annotate(string $key, mixed $value): void - { - $this->annotations[$key] = $value; - } - - public function get(string $key): mixed - { - return $this->annotations[$key] ?? null; - } -} - -$inspector = new ResolutionContext(); -$expression->withInspector($inspector)(['radius' => 5]); - -// Annotations are available via $inspector->get('label'), etc. -``` - -## Advanced Usage - -### Custom Types - -Implement the `Type` interface to create custom data validations and coercions: - -```php -, Throwable>` types: - -- `Result::Ok(Some(value))`: Successful validation/coercion with value -- `Result::Ok(None())`: Successful validation/coercion with no value (null/empty) -- `Result::Err(exception)`: Validation/coercion failed with error - -This approach ensures: -- No exceptions for normal control flow -- Explicit handling of success/failure cases -- Type-safe null handling +```bash +cd playground +npm install +npm run build +``` -## License +## Contributing -This library is open-sourced software licensed under the [MIT license](LICENSE). +Contributions are welcome, but the repository is currently in a reshape phase. +Changes should make the project more internally consistent, especially across: -## Contributing +- the language specification +- the worked examples +- the playground behavior +- the future PHP implementation direction +- the root/legacy split -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project. +See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution guidance. ## Security -If you discover any security-related issues, please review our [Security Policy](SECURITY.md) for information on how to responsibly report vulnerabilities. +See [SECURITY.md](./SECURITY.md) for vulnerability reporting guidance. diff --git a/SECURITY.md b/SECURITY.md index f37c097..f02727c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,50 +1,50 @@ # Security Policy -## Supported Versions - -We release patches for security vulnerabilities in the following versions: +## Repository Scope -| Version | Supported | -| ------- | ------------------ | -| 1.x | :white_check_mark: | +This repository currently contains three different surfaces: -## Reporting a Vulnerability +- a fresh root PHP implementation surface in [`src/`](./src) +- an experimental TypeScript playground in [`playground/`](./playground) +- specification and planning documents for the next Axiom v1 implementation +- an archived pre-v1 PHP runtime in [`legacy/`](./legacy) -If you discover a security vulnerability within this library, please send an email to the maintainers. All security vulnerabilities will be promptly addressed. +When reporting an issue, please state which surface is affected. -**Please do not report security vulnerabilities through public GitHub issues.** +## Supported Versions -### What to Include +We currently handle security issues for: -When reporting a vulnerability, please include: +| Surface | Supported | +| ------- | --------- | +| Current root PHP implementation surface | best effort | +| Current `main` branch development work | best effort | +| Archived legacy runtime | no new feature work; security triage only if still relevant | -* A description of the vulnerability -* Steps to reproduce the issue -* Potential impact of the vulnerability -* Any potential solutions you've considered +No stable Axiom v1 reference implementation has been released from this +repository yet. -### Response Timeline +## Reporting a Vulnerability -* **Initial Response**: We aim to acknowledge receipt of your vulnerability report within 48 hours -* **Status Updates**: We will send you regular updates about our progress -* **Disclosure**: Once the vulnerability is fixed, we will work with you on responsible disclosure +Please do not report security vulnerabilities in public issues. -### Security Update Process +Send the report privately to the maintainers and include: -1. The security report is received and assigned to a primary handler -2. The problem is confirmed and a list of affected versions is determined -3. Code is audited to find any similar problems -4. Fixes are prepared for all supported releases -5. New versions are released and announced +- a clear description of the issue +- the affected surface or component +- steps to reproduce +- likely impact +- any mitigation ideas you already have -## Security Best Practices +## Response Expectations -When using this library, we recommend: +- initial acknowledgement within 48 hours where possible +- follow-up once the issue is confirmed and scoped +- coordinated disclosure after a fix is available -* Keep your dependencies up to date -* Use the latest stable version of PHP (8.4+) -* Follow the principle of least privilege -* Validate and sanitize all user input -* Use type coercion methods appropriately +## Notes -Thank you for helping keep Axiom Library and its users safe! +- specification wording issues are usually not security issues unless they can + be shown to create an exploitable implementation weakness +- playground-only problems should be identified as such +- if the issue affects the archived runtime, say so explicitly diff --git a/axiom-canonical-program-format.md b/axiom-canonical-program-format.md new file mode 100644 index 0000000..cad0eb7 --- /dev/null +++ b/axiom-canonical-program-format.md @@ -0,0 +1,631 @@ +# Axiom Canonical Program Format + +## Purpose + +This document defines a UI-facing canonical program format for Axiom. + +It is intended to be the shared interchange layer between: + +- builder UIs +- imported legacy normalized JSON +- hand-authored DSL text +- the internal PHP implementation + +The canonical format is not the parser's raw AST and it is not a PHP class dump. +It is a versioned, stable, program-level JSON model that can be persisted, +rendered, migrated, and validated independently of the engine internals. + +## Role In The System + +The intended flow is: + +1. builder UI -> canonical program JSON +2. legacy normalized JSON -> canonical program JSON +3. DSL text -> parser -> canonical program JSON +4. canonical program JSON -> internal PHP AST/analyzed program +5. analyzed program -> evaluation +6. canonical program JSON -> optional DSL printer + +This gives Axiom one semantic center without forcing structured authoring tools +to emit DSL text directly. + +## Design Constraints + +The canonical format should: + +- be versioned explicitly +- represent complete programs, not just isolated expression trees +- separate stable machine identity from human-readable names +- represent tables as first-class program artifacts +- make references explicit by target kind +- avoid leaking parser-only or PHP-only implementation details +- be easy to construct from UI builders +- be easy to migrate from the legacy normalized JSON format + +## Non-Goals + +The canonical format should not: + +- mirror the internal PHP class layout one-to-one +- expose lazy evaluation or memoization internals +- encode formatter trivia or source-text round-tripping details +- preserve legacy node names when they no longer match the Axiom v1 semantics + +## Program Shape + +```json +{ + "format": "axiom.program/v1alpha1", + "meta": { + "source": "builder-ui" + }, + "inputs": [], + "tables": [], + "types": [], + "expressions": [] +} +``` + +### Top-Level Fields + +- `format`: required version tag +- `meta`: optional non-semantic metadata +- `inputs`: declared runtime inputs +- `tables`: declared external table artifacts +- `types`: optional named types used by the program +- `expressions`: named expression declarations + +## Identity Model + +Every user-authored top-level item should have: + +- `id`: stable machine identity used by persistence and migration +- `name`: printable identifier used for DSL export and diagnostics +- `label`: optional UI display text + +Example: + +```json +{ + "id": "01KHBRRYYCRA5KPZRP8650AH5J", + "name": "isMainIndustryOnlineRetailer", + "label": "Is Main Industry Online Retailer" +} +``` + +The engine should not rely on `label` for semantics. + +## Inputs + +Inputs replace the old implicit `answers` symbol namespace. + +```json +{ + "id": "01KHBRRYYDY6X95BQC50EASGHZ", + "name": "mainIndustry", + "label": "Main Industry", + "type": { + "kind": "list", + "element": { + "kind": "named", + "name": "IndustryRef" + } + } +} +``` + +### Input Invariants + +- inputs are declared once at the program level +- expression bodies refer to inputs by `input_ref` +- the old generic `SymbolSource(namespace="answers")` should not survive in the + canonical format + +## Tables + +Tables are first-class program declarations. + +```json +{ + "id": "01KHBRRY0V6Y3H4WAKKT8X3Q9C", + "name": "industryLookup", + "label": "Industry Lookup", + "artifact": { + "kind": "csv", + "path": "lookups/01KHBRRY0V6Y3H4WAKKT8X3Q9B/01KHBRRY0V6Y3H4WAKKT8X3Q9C.csv" + } +} +``` + +### Table Invariants + +- the table declaration identifies the artifact +- expressions refer to tables by `tableId` +- repeated legacy lookup nodes that reference the same table should collapse to + one top-level table declaration + +## Expressions + +Expressions are the main authored units. + +```json +{ + "id": "01KHBRRYYCRA5KPZRP8650AH5J", + "name": "isMainIndustryOnlineRetailer", + "label": "Is Main Industry Online Retailer", + "returnType": { + "kind": "primitive", + "name": "bool" + }, + "body": {} +} +``` + +### Expression Invariants + +- every expression has exactly one body node +- expression composition should use `call`, not generic symbol lookup +- the canonical format is declaration-oriented, not just a bag of anonymous + nodes + +## Type Nodes + +The UI-facing type layer should stay small and explicit. + +### Primitive Type + +```json +{ "kind": "primitive", "name": "bool" } +``` + +Supported primitive names should reflect the Axiom v1 spec, for example: + +- `number` +- `string` +- `bool` + +### Named Type + +```json +{ "kind": "named", "name": "IndustryRef" } +``` + +### List Type + +```json +{ + "kind": "list", + "element": { "kind": "named", "name": "IndustryRef" } +} +``` + +### Record Type + +```json +{ + "kind": "record", + "fields": [ + { "name": "industry", "type": { "kind": "named", "name": "IndustryRef" } }, + { "name": "turnover", "type": { "kind": "primitive", "name": "number" } } + ] +} +``` + +### Variant Type + +```json +{ + "kind": "variant", + "cases": [ + { "tag": "accept" }, + { + "tag": "refer", + "payload": { + "kind": "record", + "fields": [ + { "name": "reason", "type": { "kind": "primitive", "name": "string" } } + ] + } + } + ] +} +``` + +## Core Expression Nodes + +The initial canonical node set should cover the most common builder operations. + +### `literal` + +```json +{ "kind": "literal", "value": "DRI-749" } +``` + +### `list` + +```json +{ + "kind": "list", + "elements": [ + { "kind": "literal", "value": "DRI-749" }, + { "kind": "literal", "value": "DRI-1793" } + ] +} +``` + +### `record` + +```json +{ + "kind": "record", + "fields": [ + { "name": "industry", "value": { "kind": "literal", "value": "DRI-749" } }, + { "name": "turnover", "value": { "kind": "literal", "value": "500000" } } + ] +} +``` + +### `input_ref` + +```json +{ + "kind": "input_ref", + "inputId": "01KHBRRYYDY6X95BQC50EASGHZ" +} +``` + +Use `input_ref` for declared runtime inputs only. + +### `local_ref` + +```json +{ + "kind": "local_ref", + "name": "row" +} +``` + +Use `local_ref` for bound names introduced by `match` or table queries. + +### `call` + +```json +{ + "kind": "call", + "expressionId": "01KHBRRYYCRA5KPZRP8650AH5J", + "arguments": [] +} +``` + +Use `call` when one named expression depends on another named expression. + +### `field` + +```json +{ + "kind": "field", + "object": { "kind": "local_ref", "name": "row" }, + "name": "Product Group" +} +``` + +### `unary` + +```json +{ + "kind": "unary", + "operator": "not", + "operand": { "kind": "input_ref", "inputId": "01..." } +} +``` + +### `binary` + +```json +{ + "kind": "binary", + "operator": "intersects", + "left": { "kind": "input_ref", "inputId": "01..." }, + "right": { "kind": "list", "elements": [] } +} +``` + +Typical UI-facing binary operators include: + +- `==` +- `!=` +- `>` +- `>=` +- `<` +- `<=` +- `and` +- `or` +- `+` +- `-` +- `*` +- `/` +- `in` +- `intersects` + +### `match` + +```json +{ + "kind": "match", + "subject": { "kind": "input_ref", "inputId": "01..." }, + "arms": [ + { + "pattern": { "kind": "literal_pattern", "value": "micro" }, + "value": { "kind": "literal", "value": "1.3" } + }, + { + "pattern": { "kind": "wildcard_pattern" }, + "value": { "kind": "literal", "value": "1.0" } + } + ] +} +``` + +### `annotate` + +```json +{ + "kind": "annotate", + "type": { "kind": "list", "element": { "kind": "named", "name": "IndustryRef" } }, + "expression": { "kind": "list", "elements": [] } +} +``` + +`annotate` exists for cases where the authoring surface needs to pin a type +explicitly. It should not be used as a generic wrapper around every expression. + +### `table_query` + +```json +{ + "kind": "table_query", + "mode": "first", + "tableId": "01KHBRRY0V6Y3H4WAKKT8X3Q9C", + "binding": "row", + "where": { + "kind": "binary", + "operator": "==", + "left": { + "kind": "field", + "object": { "kind": "local_ref", "name": "row" }, + "name": "ID" + }, + "right": { + "kind": "input_ref", + "inputId": "01KHBRRYYDY6X95BQC50EASGHZ" + } + }, + "select": { + "kind": "field", + "object": { "kind": "local_ref", "name": "row" }, + "name": "Product Group" + } +} +``` + +`mode` should begin with: + +- `first` +- `all` + +This keeps the canonical format close to the current legacy lookup behavior +while still modeling tables as first-class declarations. + +## Pattern Nodes + +The initial pattern set should remain small: + +- `literal_pattern` +- `wildcard_pattern` +- `tag_pattern` + +If expression-based guard patterns are needed in the canonical format, define +them explicitly rather than carrying legacy pattern node names forward. + +## Canonical Example + +The following shows a simplified rewrite of two legacy variables into the +canonical format. + +```json +{ + "format": "axiom.program/v1alpha1", + "inputs": [ + { + "id": "01KHBRRYYDY6X95BQC50EASGHZ", + "name": "mainIndustry", + "type": { + "kind": "list", + "element": { + "kind": "named", + "name": "IndustryRef" + } + } + } + ], + "tables": [ + { + "id": "01KHBRRY0V6Y3H4WAKKT8X3Q9C", + "name": "industryLookup", + "artifact": { + "kind": "csv", + "path": "lookups/01KHBRRY0V6Y3H4WAKKT8X3Q9B/01KHBRRY0V6Y3H4WAKKT8X3Q9C.csv" + } + } + ], + "expressions": [ + { + "id": "01KHBRRYYCRA5KPZRP8650AH5J", + "name": "isMainIndustryOnlineRetailer", + "label": "Is Main Industry Online Retailer", + "returnType": { "kind": "primitive", "name": "bool" }, + "body": { + "kind": "binary", + "operator": "intersects", + "left": { + "kind": "input_ref", + "inputId": "01KHBRRYYDY6X95BQC50EASGHZ" + }, + "right": { + "kind": "list", + "elements": [ + { "kind": "literal", "value": "DRI-749" }, + { "kind": "literal", "value": "DRI-1793" }, + { "kind": "literal", "value": "DRI-1794" }, + { "kind": "literal", "value": "DRI-1795" } + ] + } + } + }, + { + "id": "01KHBRRYYCRA5KPZRP8650AH5M", + "name": "industryGroupMainIndustry", + "label": "Industry Group Lookup - Main Industry", + "body": { + "kind": "table_query", + "mode": "first", + "tableId": "01KHBRRY0V6Y3H4WAKKT8X3Q9C", + "binding": "row", + "where": { + "kind": "binary", + "operator": "==", + "left": { + "kind": "field", + "object": { "kind": "local_ref", "name": "row" }, + "name": "ID" + }, + "right": { + "kind": "input_ref", + "inputId": "01KHBRRYYDY6X95BQC50EASGHZ" + } + }, + "select": { + "kind": "field", + "object": { "kind": "local_ref", "name": "row" }, + "name": "Product Group" + } + } + } + ] +} +``` + +## Migration From Legacy Normalized JSON + +### Top-Level Legacy Shape + +Current legacy payloads appear to use: + +```json +{ + "variables": [ ... ] +} +``` + +Migration should lift this into a full program: + +- variable list -> `expressions` +- `answers` references -> `inputs` +- repeated `LookupSource` tables -> deduplicated `tables` + +### Legacy Node Mapping + +- `StaticValue` -> `literal` +- `ListSource` -> `list` +- `SymbolSource(namespace="answers")` -> `input_ref` +- `SymbolSource` pointing to another variable -> `call` +- `InfixExpression` -> `binary` +- `TypeDefinition` -> remove where redundant, otherwise `annotate` +- `LookupSource` -> top-level `table` + `table_query` + +### `SymbolSource` Rule + +The old generic symbol node should not survive in the canonical format. + +Use: + +- `input_ref` for runtime inputs +- `local_ref` for locally bound names +- `call` for dependencies on other named expressions + +This makes the reference target explicit and improves static analysis. + +### `TypeDefinition` Rule + +Legacy `TypeDefinition` often acts as a general wrapper rather than a meaningful +type annotation. The importer should: + +- drop it when the type is already implied by the surrounding node +- preserve it as `annotate` only when the type information is semantically + important + +### `LookupSource` Rule + +`LookupSource` is not a core expression node in the rewritten v1 language. It +is a legacy packaged lookup abstraction that should be lowered into: + +- a table declaration +- a table query expression + +Legacy fields: + +- `id`, `path` -> table declaration +- `aggregate` -> `table_query.mode` +- `filters` -> `table_query.where` +- `columns` -> `table_query.select` + +When `columns` contains exactly one field, the importer can select that field +directly. If multiple columns are requested, the importer should produce a +record-valued selection instead of inventing bespoke lookup semantics. + +## UI Authoring Guidance + +The builder UI should target the canonical format directly. + +Recommended builder primitives: + +- choose input +- call expression +- literal value +- list literal +- binary comparison/arithmetic +- boolean combination +- field selection from bound row +- table query with filters +- match with ordered arms +- explicit type annotation only when needed + +The UI should not need to know about: + +- lexer tokens +- parser recovery +- PHP AST classes +- engine memoization or runtime internals + +## Relationship To Internal PHP AST + +The PHP engine should hydrate this canonical format into internal classes under +`src/`. + +Those internal classes may evolve as implementation details change. + +The canonical format should remain the stable contract for: + +- UI authoring +- persistence +- import/export +- migration +- test fixtures + +## Suggested Next Steps + +1. Freeze the legacy format as an import-only compatibility layer. +2. Implement a PHP importer from legacy JSON into the canonical format. +3. Add canonical-format fixtures under `tests/Conformance/`. +4. Implement the internal hydrator from canonical JSON into PHP AST objects. +5. Add a DSL printer after the canonical format and internal AST are stable. diff --git a/axiom-php-implementation-plan.md b/axiom-php-implementation-plan.md new file mode 100644 index 0000000..79237f3 --- /dev/null +++ b/axiom-php-implementation-plan.md @@ -0,0 +1,662 @@ +# Axiom PHP Implementation Plan + +## Purpose + +This document describes a concrete plan for implementing the rewritten Axiom v1 +specification in PHP as the canonical runtime. + +The goal is not to keep the current playground and PHP library loosely aligned. +The goal is to make the PHP implementation the reference engine for: + +- parsing +- type checking +- artifact validation +- deterministic evaluation +- extension loading +- conformance testing + +## Why PHP + +PHP is a good fit for the real Axiom implementation because: + +- all current applications are already in PHP +- Axiom evaluation is primarily a server-side concern +- integration with product apps, persistence, deployment, and artifacts will be simpler +- `brick/math` is already present and gives a solid base for exact decimal semantics +- `brick/money` can be added later for the standardized money extension + +The browser playground can remain as: + +- a non-canonical prototype +- a thin client that calls the PHP engine +- or a disposable implementation once PHP becomes authoritative + +## Current Starting Point + +The repository now has: + +- a fresh root PHP scaffold under `src/` and `tests/` +- an archived pre-v1 runtime under `legacy/` +- a TypeScript playground under `playground/` + +Relevant archived legacy areas: + +- `legacy/src/Resolvers/*` +- `legacy/src/Patterns/*` +- `legacy/src/Operators/*` +- `legacy/src/Types/*` +- `legacy/src/Sources/*` + +Important mismatch between the archived runtime and the rewritten spec: + +- the legacy runtime includes `DictType` +- the legacy runtime includes `NullOverloader` +- the legacy runtime is expression-centric, not program/AST/typechecker-centric +- the legacy runtime does not model tables as ordered validated artifacts +- the legacy runtime does not enforce v1 semantics like no indexing and static division safety + +Because of that, the new PHP implementation should not be developed as an +incremental patch to the old mental model. The fresh root scaffold should stay +conceptually separate, with selective reuse from `legacy/` only where it still +fits the v1 design. + +## Recommended Strategy + +Build the Axiom v1 engine under a fresh root package structure in `src/`, then +migrate reusable pieces from `legacy/` deliberately if they still make sense. + +Recommended namespace: + +```php +Superscript\Axiom\ +``` + +The old runtime is already archived under `legacy/`, so the fresh root package +can use the direct namespace without inheriting conflicting concepts. + +## Guiding Constraints From The v1 Spec + +The PHP implementation must preserve these rules from the current spec: + +- exact decimal `number`, not float semantics +- `non_zero` refinement and static division safety +- no `dict(T)` core type +- no indexing +- records as the only object-shaped structured core value +- tables as ordered immutable artifact-backed lists +- no silent fallback semantics +- lazy evaluation with memoization +- narrow extension model: literals, types, operators, intrinsics, total coercions +- extension composition must not depend on registration order + +## Target Architecture + +The canonical PHP implementation should have these subsystems: + +1. `Lexer` +2. `Parser` +3. `AST` +4. `NameResolver` +5. `TypeSystem` +6. `TypeChecker` +7. `ArtifactValidator` +8. `InputValidator` +9. `Evaluator` +10. `Extensions` +11. `Diagnostics` +12. `Conformance` + +Recommended package layout: + +```text +src/Ast/ +src/Lexing/ +src/Parsing/ +src/Names/ +src/Types/ +src/Typing/ +src/Artifacts/ +src/Input/ +src/Eval/ +src/Extensions/ +src/Diagnostics/ +src/Runtime/ +src/Conformance/ +src/Values/ +``` + +## Core Data Model + +Do not use raw PHP arrays as the semantic model for Axiom values. + +Use explicit value objects: + +- `DecimalValue` +- `StringValue` +- `BooleanValue` +- `ListValue` +- `RecordValue` +- `VariantValue` +- extension values such as `MoneyValue` + +Use explicit type objects: + +- `NumberType` +- `NonZeroType` +- `StringType` +- `BooleanType` +- `ListType` +- `InlineRecordType` +- `NamedRecordType` +- `VariantType` +- `NamedTypeReference` +- extension types such as `MoneyType` + +Reason: + +- PHP arrays blur list, record, and map semantics +- the v1 spec explicitly does not +- explicit value and type objects will prevent semantic drift + +## Runtime Program Model + +The main production unit should be an analyzed program, not raw source text. + +Recommended objects: + +- `ProgramBundle` + - source text + - table artifacts + - enabled extensions +- `ParsedProgram` + - AST + - parse diagnostics +- `AnalyzedProgram` + - AST + - symbol tables + - resolved declarations + - typed nodes + - validated table schemas + - recursion analysis + - extension validation +- `Runtime` + - `evaluate(string $expressionName, array $input): Value` + +The engine should parse and analyze once, then evaluate many times. + +## Parser Approach + +Use a handwritten recursive-descent parser in PHP. + +Why: + +- the grammar is small and controlled +- diagnostics matter +- grammar changes are still likely during implementation +- a handwritten parser is easier to evolve than a generated one here + +Deliverables: + +- token definitions +- lexer with source locations +- AST node classes +- parser with declaration-level recovery +- parser diagnostics + +## Type System And Static Semantics + +The type checker is the heart of the implementation. It should be a real phase, not +runtime checking disguised as evaluation. + +Responsibilities: + +- declaration collection +- namespace resolution +- named type registration +- inline record shape checking +- named-vs-inline assignability rules +- variant constructor resolution +- variant pattern validation +- match exhaustiveness +- collection form typing +- table row typing +- recursion detection +- static division safety +- extension hook type participation + +The output should include resolved type information for every expression node. + +## Numeric Semantics + +Use `brick/math` and never evaluate Axiom `number` using PHP `float`. + +Rules: + +- literals parse to `BigDecimal` +- arithmetic uses exact decimal operations +- serialization at boundaries uses strings +- `non_zero` is a static type property, not a runtime convention + +Do not defer this. If the engine starts with native PHP floats, the implementation +will drift from the spec immediately. + +## Tables And Artifacts + +Tables are a first-class language feature and should be modeled as such. + +Recommended components: + +- `ArtifactRepository` +- `TableSchema` +- `ValidatedTable` +- `ArtifactValidator` +- `TableLoader` + +Rules to enforce: + +- artifacts are required for declared tables +- rows must conform to declared record shape +- artifact row order is preserved +- evaluation sees tables as immutable ordered lists + +The runtime may add internal indexes later, but only as an optimization. + +## Input Validation + +Separate boundary validation from evaluation. + +Recommended components: + +- `InputValidator` +- `InputCoercer` only if coercions are explicitly total and spec-approved + +Rules: + +- validate target expression parameters before evaluation +- reject invalid input before evaluation begins +- do not silently manufacture domain values +- keep parsing/normalization concerns at the boundary, not in business logic + +## Evaluation + +Start with direct AST evaluation. + +Recommended components: + +- `EvaluationContext` +- `Scope` +- `Thunk` +- `MemoizedBinding` +- `Evaluator` + +Required behavior: + +- lazy arguments +- lazy `where` bindings +- one computation per binding per scope +- no mutation +- no side effects +- deterministic list/table iteration order + +Do not compile to PHP code in the first implementation. + +## Extensions + +The extension system should match the narrowed v1 spec. + +Allowed extension capabilities: + +- literal recognition +- custom type families +- operator typing/runtime rules +- intrinsic overloads +- total coercions + +Disallowed in v1: + +- new control-flow forms +- new keywords +- new pattern syntax +- external data access +- registration-order-dependent behavior + +Recommended extension interfaces: + +- `LiteralExtension` +- `TypeExtension` +- `OperatorExtension` +- `IntrinsicExtension` +- `CoercionExtension` + +Recommended registry behavior: + +- validate overlaps at program load time +- fail fast on extension conflicts +- make extension composition deterministic + +## Standardized Money Extension + +The money extension should be implemented after the core engine is stable. + +Required additions: + +- add `brick/money` dependency +- `MoneyType` +- `MoneyValue` +- money literal parsing +- money arithmetic rules +- money comparison rules +- intrinsic overloads for `round`, `sum`, and other approved intrinsics + +Money should remain an extension, not a core type. + +## Conformance Testing + +This is critical. The spec, PHP engine, and playground will drift unless there is +one shared test suite. + +Build a conformance suite with: + +- lexer tests +- parser tests +- AST snapshot tests +- type-check tests +- negative diagnostics tests +- table artifact validation tests +- evaluation tests +- extension tests + +Recommended structure: + +```text +tests/Conformance/Lexing/ +tests/Conformance/Parsing/ +tests/Conformance/Typing/ +tests/Conformance/Artifacts/ +tests/Conformance/Eval/ +tests/Conformance/Extensions/ +``` + +The examples in `axiom-v1-spec.md` should become executable conformance tests. + +## Suggested Delivery Phases + +### Phase 0 - Project Setup + +Goals: + +- create `Superscript\Axiom\V1\` structure +- decide coexistence strategy with current runtime +- add fixtures and baseline test harness + +Deliverables: + +- directory structure +- base diagnostics types +- source location model +- test conventions + +Acceptance: + +- empty v1 package compiles +- PHPUnit and PHPStan include the new namespace cleanly + +### Phase 1 - AST, Lexer, Parser + +Goals: + +- parse the rewritten core grammar + +Deliverables: + +- token model +- lexer +- AST classes +- parser +- parse diagnostics + +Acceptance: + +- all spec examples parse +- parser recovery works at declaration boundaries + +### Phase 2 - Declaration And Name Resolution + +Goals: + +- resolve types, namespaces, expressions, and tables + +Deliverables: + +- declaration registry +- namespace-aware symbol resolution +- duplicate-name diagnostics + +Acceptance: + +- unresolved symbols are detected cleanly +- duplicate declarations are rejected + +### Phase 3 - Type System And Core Type Checking + +Goals: + +- implement the core v1 type system + +Deliverables: + +- type objects +- assignability rules +- record and variant checking +- constructor resolution +- match exhaustiveness +- collection-form typing + +Acceptance: + +- spec examples type-check or fail with correct diagnostics + +### Phase 4 - Numeric Refinement And Division Safety + +Goals: + +- implement `non_zero` and static division safety + +Deliverables: + +- refined numeric typing +- narrowing for approved forms +- division diagnostics + +Acceptance: + +- unsafe division is rejected statically +- safe division cases from conformance tests pass + +### Phase 5 - Artifacts And Input Validation + +Goals: + +- make tables and validated inputs real runtime boundaries + +Deliverables: + +- table schemas +- artifact loading and validation +- input validator + +Acceptance: + +- invalid artifacts fail before evaluation +- invalid inputs fail before evaluation +- table iteration preserves artifact order + +### Phase 6 - Evaluator + +Goals: + +- evaluate analyzed programs deterministically + +Deliverables: + +- lazy evaluator +- memoization model +- runtime values +- variant and record runtime representation + +Acceptance: + +- core spec example evaluates correctly +- evaluator uses no float arithmetic for `number` + +### Phase 7 - Extensions + +Goals: + +- add the narrow v1 extension model + +Deliverables: + +- extension registry +- overlap validation +- literal/type/operator/intrinsic extension hooks + +Acceptance: + +- extension conflicts fail at load time +- core language behavior does not depend on extension order + +### Phase 8 - Standardized Money Extension + +Goals: + +- implement the money extension against the stabilized extension API + +Deliverables: + +- money literal parser +- money type/value +- money arithmetic and comparisons +- intrinsic overloads + +Acceptance: + +- money example passes as conformance tests +- cross-currency violations fail statically + +### Phase 9 - Tooling Integration + +Goals: + +- connect the PHP engine to the rest of the developer workflow + +Deliverables: + +- API for evaluating programs from applications +- optional HTTP endpoint for playground/editor integration +- fixture export for cross-runtime comparison + +Acceptance: + +- playground can be backed by PHP if desired +- applications can evaluate analyzed programs without reparsing every request + +## Recommended Near-Term Decisions + +Before implementation starts, lock these in explicitly: + +1. The PHP engine is the canonical implementation. +2. The current TS playground is non-canonical. +3. v1 lives under a new namespace/package boundary. +4. Existing conflicting concepts such as `DictType` and `NullOverloader` are not + part of the new engine. +5. The spec examples become conformance fixtures. + +## Main Risks + +### 1. Reusing Too Much Of The Old Runtime + +Risk: + +- carrying over `dict`/`null`/resolver assumptions that now conflict with the spec + +Mitigation: + +- implement v1 in a new namespace with explicit imports from old code only when justified + +### 2. Cheating On Decimal Semantics + +Risk: + +- accidental use of PHP floats + +Mitigation: + +- wrap `BigDecimal` in a dedicated numeric value model from day one + +### 3. Type Checker Creep + +Risk: + +- pushing semantics into the evaluator because static analysis feels slower to build + +Mitigation: + +- require that every major semantic feature ships with type-check tests first + +### 4. Playground Drift + +Risk: + +- TypeScript and PHP becoming two dialects again + +Mitigation: + +- make the PHP engine canonical and test the playground against PHP outputs where needed + +## Recommended First Implementation Slice + +If the aim is to get a serious vertical slice quickly, build this first: + +- expression declarations +- record and variant types +- `if` +- subject `match` +- `where` +- `list(T)` +- `sum`, `product`, `round`, `len`, `flatten` +- tables +- exact decimal numbers +- `non_zero` +- direct evaluation + +Defer until the slice is stable: + +- namespaces beyond basics +- mixed call style ergonomics +- extension system +- money +- rich editor integration + +## Definition Of Done For v1 PHP Engine + +The PHP implementation is ready to be called the Axiom v1 reference runtime when: + +- it parses the normative grammar +- it enforces the core type system from the spec +- it validates inputs and table artifacts before evaluation +- it evaluates deterministically using exact decimals +- it rejects unsafe division statically +- it loads non-overlapping extensions deterministically +- it passes a conformance suite derived from the spec and examples + +## Next Step + +The root package skeleton now exists. The next concrete step should be: + +1. populate `tests/Conformance/` with the first fixture-driven cases +2. implement lexer tokenization and source locations +3. implement AST construction and parser diagnostics + +That keeps the implementation on a stable path without prematurely coupling it +to the older expression resolver model. diff --git a/axiom-v1-spec.md b/axiom-v1-spec.md index 427c920..5d2e6ba 100644 --- a/axiom-v1-spec.md +++ b/axiom-v1-spec.md @@ -1,36 +1,58 @@ -# Axiom v1 — Language Specification +# Axiom v1 - Language Specification -Axiom is a statically-typed expression language for declarative, type-safe computation. It is designed for authored business logic: rating, pricing, eligibility, financial calculations, and domain rules. +Axiom is a statically typed expression language for declarative business computation. +It is designed for authored logic such as pricing, eligibility, financial calculation, +classification, and product rules. -Axiom is not a general-purpose programming language. It is a small, reviewable language for computing values from typed inputs. +Axiom is not a general-purpose programming language. It is a small, reviewable +language for computing values from typed inputs and versioned table artifacts. The core guarantee of Axiom v1 is: -> If a program parses, type-checks, and its runtime inputs validate, then evaluating it cannot fail. +> If a program parses, type-checks, all referenced table artifacts validate, and +> its runtime inputs validate, then evaluating a target expression deterministically +> produces a value of the declared result type without runtime exceptions. + +This document is normative unless a section is explicitly marked as informative. +The grammar in Section 13 is normative for syntax. --- ## 1. Design Principles -1. **Expressions are the unit of authorship.** An Axiom program is a collection of named, typed expressions. Each expression has typed parameters and evaluates to exactly one value. +1. **Expressions are the unit of authorship.** An Axiom program is a collection of + named, typed expressions. Each expression evaluates to exactly one value. + +2. **Reviewability beats cleverness.** The language prefers explicit, local + constructs over compact or highly abstract ones. -2. **Reviewability beats cleverness.** The language prefers explicit, local constructs over compact or highly abstract ones. A business stakeholder should be able to read an Axiom program and follow the logic. +3. **The core is pure.** Expressions, operators, constructors, collection forms, + and expression calls are deterministic and side-effect free. -3. **The core is pure.** Expressions, operators, coercions, constructors, collection forms, and expression calls are deterministic and side-effect free. +4. **Tables are core.** Versioned table artifacts are part of the language model. + Arbitrary external IO is not. -4. **External data access is a boundary concern.** Host-provided functions for external data (CSV lookups, APIs, databases) are provided through the extension system, not as core language constructs. See §14. +5. **Static proof first, runtime execution second.** Parsing, type checking, table + validation, and input validation establish the preconditions for safe execution. -5. **Static proof first, runtime execution second.** Parsing, type checking, and runtime input validation establish the preconditions for safe execution. +6. **The language stays narrow.** Axiom v1 intentionally excludes mutation, loops, + recursion as a control structure, exceptions, implicit IO, dynamic maps, and + syntax-extending plugins. -6. **The language stays narrow.** Axiom v1 intentionally excludes mutation, loops, user-defined higher-order functions, recursion as a control structure, exceptions, implicit IO, and arbitrary control abstractions. +7. **Named types are nominal.** Named variant and record types are identified by + name, not by accidental structural equivalence. -7. **Types are nominal.** Named variant types are identified by name, not structure. Accidental structural equivalence between unrelated types does not make them assignable. +8. **Meaningful failure is modeled in values.** Domain outcomes such as decline, + referral, or non-availability are represented in variants, not in an out-of-band + error channel. -8. **Expressions return values.** Every named expression evaluates to a value. Meaningful domain outcomes (decline, referral, availability) are represented in that value, not in an out-of-band channel. +9. **No null.** There is no `null` type in Axiom v1. -9. **No null.** There is no `null` type. Meaningful absence is modeled with variants. +10. **Defaults must be explicit.** Axiom does not silently substitute domain values + for invalid or absent data. Fallbacks must be authored in the program. -10. **General-purpose constructs over domain-specific ones.** Language features should be useful beyond any single domain, even though the primary use case is insurance and financial computation. +11. **General-purpose constructs over domain-specific ones.** Core language features + should be useful beyond a single business domain. --- @@ -43,21 +65,32 @@ Axiom v1 does not provide: - recursion as a user-facing control structure - exceptions or `try/catch` - implicit IO or side effects +- dynamic maps or dictionary types +- indexing (`xs[0]`, `record["field"]`) - hidden type coercions -- unrestricted anonymous unions -- `null` or optional/nullable types (`T?`) — use variants for meaningful absence -- string interpolation or concatenation — strings are codes and identifiers, not prose -- general-purpose higher-order functions (`map`, `filter`, `reduce`) — use collection forms instead +- `null` or optional/nullable types (`T?`) +- syntax-extending plugins +- string interpolation or free-form text templating +- general-purpose higher-order functions (`map`, `filter`, `reduce`) --- ## 3. Program Structure -An Axiom program is a collection of top-level declarations. There is no "main" expression — any named expression may be targeted for execution. +An Axiom program is a collection of top-level declarations. There is no `main` +expression. Any named expression may be targeted for evaluation. + +Top-level declarations are: + +- expression declarations +- type declarations +- namespace declarations +- table declarations ### 3.1 Expression Declarations -A named expression has a name, typed parameters, an optional return type annotation, and a body. +A named expression has a name, zero or more typed parameters, a declared return +type, and a body expression. ```axiom BasePremium(sum_insured: number, rate: number): number { @@ -65,6 +98,14 @@ BasePremium(sum_insured: number, rate: number): number { } ``` +Zero-argument expressions are written explicitly with empty parentheses: + +```axiom +AdminFee(): number { + 35 +} +``` + Parameters may use inline record shapes: ```axiom @@ -81,123 +122,121 @@ Rating(exposure: Exposure, limit: number): number { } ``` -Return type annotations are optional. When omitted, the type checker infers the return type from the body expression. Annotations are required when: - -- The body produces a variant type that must be nominally bound to a declared type name -- The inferred type would be ambiguous +Return types are required in v1. This keeps variant construction and expression +interfaces explicit. ### 3.2 Type Declarations #### Variant types -A variant type is a closed set of tagged alternatives. Each alternative has a tag and an optional typed payload. +A variant type is a closed set of tagged alternatives. Each alternative has a tag +and an optional record payload. ```axiom type CoverOutcome = rated { key: string, name: string, - base_premium: number, - limit: number, - excess: number, + premium: number, } | not_available { reason: string } ``` -#### Payload-less variants - -Variant alternatives may omit the payload when no data is needed: +Payload-less variants omit the payload: ```axiom type Status = active | suspended | cancelled - -type Decision = - approved { premium: number } - | declined - | referred { reason: string } ``` -Payload-less tags are valid in both construction and pattern matching positions. At runtime they are represented as `{ _tag: "tagname" }` with no additional fields. - #### Record types -A record type declares a named shape without variant tags: +A record type declares a fixed set of named fields: ```axiom type Exposure = { industry: string, turnover: number, - number_of_employees: number, + employees: number, } ``` -Record types are used to structure input parameters and intermediate data. At runtime, record values are plain associative structures (dicts with known shapes). Member access on record-typed values is validated against the declared shape. +Records are the only object-shaped structured value in Axiom v1. ### 3.3 Namespace Declarations -Namespaces group related types, expressions, and constant symbols: +Namespaces group related types and expressions. ```axiom namespace Industry { - BuildingsFireClass(industry: string): string { - match industry { - "DRI-945" | "DRI-946" => "B", - _ => "Y", - } - } - - BaseExcess(industry: string): number { + BaseRate(industry: string): number { match industry { - "DRI-945" => 500, - "DRI-946" => 250, - _ => 100, + "DRI-945" => 0.85, + _ => 1.00, } } } ``` -Namespace members are accessed with qualified names: `Industry.BuildingsFireClass("DRI-945")`. +Namespace members are accessed with qualified names: +`Industry.BaseRate("DRI-945")`. Namespaces may contain: -- **Expression declarations** — callable via `Namespace.Name(args)` -- **Type declarations** — referenced via `Namespace.TypeName` -- **Symbol declarations** — constant values with type annotations: `pi: number = 3.14159` +- expression declarations +- type declarations ### 3.4 Table Declarations -A table declaration defines a named, typed, immutable list of records. The data comes from a companion artifact (e.g., a CSV file) that is bundled with the program version. +A table declaration defines a named, typed, immutable list of records. The data +comes from a companion artifact bundled with the program version. ```axiom table industry_config: list({ code: string, - buildings_fire_class: string, - pl_class: string, - pl_severity: number, - deep_frying_rate: number, - min_premium_default: number, + base_rate: number, + min_premium: number, }) ``` -Table declarations serve two purposes: +Or, using a named record type: + +```axiom +type IndustryRow = { + code: string, + base_rate: number, + min_premium: number, +} -1. **Schema declaration** — the type checker validates all access to the table's fields at compile time -2. **Artifact binding** — the runtime loads the companion data file, validates it against the declared schema, and provides the rows as an immutable list +table industry_config: list(IndustryRow) +``` -#### Design properties +Tables serve two purposes: -- **Declarative**: the language declares WHAT data exists and its shape; the runtime decides HOW to store and retrieve it (CSV scan, SQLite index, hash lookup) -- **Immutable**: table data cannot be modified by the program — it is fixed per program version -- **Pure**: tables are just typed lists — all existing list operations (`match ... in`, `collect ... in`, `any ... in`, `all ... in`) work on tables -- **Validated**: the runtime validates the artifact against the declared schema at load time, before any expression is evaluated +1. **Schema declaration.** The type checker validates field access against the + declared row shape. +2. **Artifact binding.** The runtime loads the companion artifact, validates every + row against the declared schema, and exposes the rows as an immutable list. + +#### Table semantics + +- A table is semantically an ordered immutable list of records. +- Artifact row order is part of program semantics. +- `match row in table { ... }` returns the first matching row in artifact order. +- `collect row in table { ... }` preserves artifact order. +- Implementations may optimize physical storage or access strategy, but they must + produce the same result as evaluating against the table as an in-memory immutable + list in artifact order. #### Artifacts -A program bundle consists of source code + artifacts. Artifacts are companion data files that are: +A program bundle consists of source code plus table artifacts. For a program to +load successfully: + +- every referenced artifact must be present +- every row must conform to the declared row schema +- the artifact must preserve row order -- Version-locked to the program (changing the CSV means a new version) -- Validated against table schemas at deploy/load time -- Storage-format-agnostic from the language's perspective (CSV in development, SQLite in production — same program, same results) +Artifact validation failures are load-time failures, not evaluation-time failures. --- @@ -205,82 +244,112 @@ A program bundle consists of source code + artifacts. Artifacts are companion da ### 4.1 Primitive Types -| Type | Description | Literals | -|------|-------------|----------| -| `number` | Numeric values | `42`, `3.14`, `-1` | -| `string` | Text values | `"hello"`, `"DRI-945"` | -| `bool` | Boolean values | `true`, `false` | - -### 4.2 Compound Types +| Type | Description | +|------|-------------| +| `number` | Arbitrary-precision decimal number | +| `non_zero` | Numeric value proven not equal to `0` | +| `string` | Text value | +| `bool` | Boolean value | -| Type | Description | Literals | -|------|-------------|----------| -| `list(T)` | Ordered collection of elements | `[1, 2, 3]`, `["a", "b"]` | -| `dict(T)` | Key-value mapping | `{ key: "value", other: 42 }` | +`number` is exact decimal, not IEEE 754 float. Conforming implementations must use +arbitrary-precision decimal semantics. -### 4.3 Named Types +### 4.2 Compound Types -Named types are declared with `type` and are nominal — two types with different names are distinct even if structurally identical. +| Type | Description | +|------|-------------| +| `list(T)` | Ordered collection of elements of type `T` | -**Variant types** model values that can be one of several tagged shapes: +There is no core map or dictionary type in v1. -```axiom -type ProductOutcome = - offered { total: number, covers: dict(CoverOutcome) } - | declined { reasons: list(string) } - | referred { reasons: dict(string) } -``` +### 4.3 Named Types -**Record types** model values with a single known shape: +Named record types and named variant types are nominal. ```axiom type Exposure = { industry: string, turnover: number } + +type Decision = + approved { premium: number } + | declined ``` -### 4.4 Parameterized Types +### 4.4 Extension Types -Types may accept parameters. The core provides `list(T)` and `dict(T)`. Extensions may define additional parameterized types such as `money(GBP)`. +Extensions may define additional parameterized types such as `money(GBP)`. +Extension types are not part of the core type hierarchy unless explicitly imported +through the extension mechanism described in Section 15. ### 4.5 Type Assignability -Axiom uses nominal typing for named types and structural compatibility for dicts and inline shapes. - Rules: -1. **Primitives**: same name = assignable. `number` to `number`, `string` to `string`, etc. -2. **Named variant types**: same name = same type. Structural equivalence is not sufficient. -3. **Lists**: `list(T)` is assignable to `list(U)` when `T` is assignable to `U`. -4. **Dicts/records**: structural compatibility — all required fields must exist with compatible types. -5. **`mixed`**: a special internal type assignable to any target. Used for intrinsic function parameters that accept any type. +1. **Primitives.** A type is assignable to itself. +2. **Numeric refinement.** `non_zero` is assignable to `number`. +3. **Lists.** `list(T)` is assignable to `list(U)` when `T` is assignable to `U`. +4. **Named variants.** Same declared name means same type. +5. **Named records.** Two differently named record types are not assignable to each + other, even if their fields are identical. +6. **Inline record shapes.** Inline record values are assignable to inline record + shapes when all required fields exist with assignable types. +7. **Named record to inline shape.** A value of named record type `R` is assignable + to an inline record shape when `R` contains at least the required fields with + assignable types. +8. **Inline record literal to named record.** An inline record literal is assignable + to a named record type when used in a context expecting that named type and it + provides exactly the fields of that type with assignable values. This is + contextual construction, not structural equivalence between named records. -### 4.6 Type Coercion +### 4.6 Member Access -Explicit coercion with `as` is required — Axiom never performs hidden coercions. +Member access (`expr.field`) is valid when the type has a known field set: -Valid coercions: +- named record types +- inline record shapes +- variant types only when the field exists on every alternative with the same type -| From | To | -|------|-----| -| `string` | `number` | -| `number` | `string` | +If a field exists only on some variant alternatives, the value must be narrowed +with `match` first. + +### 4.7 Numeric Refinement and Division Safety + +Division is total in Axiom v1 because it is restricted statically. + +Rule: -Extensions may register additional coercion paths (e.g., `string` ↔ `money`, `number` ↔ `money`). +- `left / right` is valid only when `right` is assignable to `non_zero` -### 4.7 Member Access +Sources of `non_zero`: -Member access (`expr.property`) is valid when the type has a known shape declaring that property: +- a parameter, field, or expression explicitly annotated as `non_zero` +- a numeric literal other than `0` +- recognized narrowing forms such as: + - `if x != 0 then ... else ...` + - `if x > 0 then ... else ...` + - `if x < 0 then ... else ...` + - match arms whose range pattern excludes `0` -- Record types: access validated against declared fields -- Dict literals with known shape: access validated against inferred fields -- Expression parameters with inline shapes: access validated against declared shape -- Variant types: access is legal **only** when the field exists on **every** alternative with the same type. Otherwise, narrow with `match` first. +If the type checker cannot prove that the divisor is `non_zero`, the program is +rejected. -### 4.8 Index Access +Arithmetic on `non_zero` values usually produces plain `number` unless another rule +proves a refined result. + +### 4.8 Coercion + +Axiom supports explicit coercion with `as`, but only for conversions that are total +and specified. + +Core coercions: + +| From | To | +|------|----| +| `number` | `string` | -Index access (`expr[index]`) is valid on: +Core Axiom v1 does not define `string as number`. -- `list(T)[number]` → `T` -- `dict(shape)["key"]` → type of the keyed field, when the key is statically known +Extensions may register additional total coercions. A coercion may not silently +invent a domain value for invalid input. --- @@ -291,182 +360,153 @@ All computation in Axiom is expressed through expressions. There are no statemen ### 5.1 Literals ```axiom -42 // number -3.14 // number -"hello" // string -true // bool -false // bool -[1, 2, 3] // list(number) -{ key: "value" } // dict +42 +3.14 +"hello" +true +false +[1, 2, 3] +{ code: "DRI-945", rate: 0.85 } ``` +Numeric literals other than `0` are assignable to `non_zero`. + ### 5.2 Identifiers and Member Access ```axiom -turnover // identifier (resolved from scope) -exposure.industry // member access -exposure.address.postcode // chained member access -items[0] // index access +turnover +exposure.industry +row.base_rate ``` ### 5.3 Arithmetic and Comparison ```axiom base_rate * (sum_insured / 1000) -total ** 2 -premium % 100 -turnover >= 50000 && not is_cancelled -trade not in ["asbestos", "demolition"] +turnover >= 50000 +industry not in ["asbestos", "demolition"] ``` -See §6 for the full operator table. +See Section 6 for the operator table. ### 5.4 If / Then / Else -`if/then/else` is an expression — both branches must produce a value. +`if/then/else` is an expression. ```axiom if claims_count == 0 then 0.95 - else 1.0 + else 1.00 ``` -Chained conditions with `else if`: +Chained conditions are written with `else if`: ```axiom if claims == 0 then 0.9 else if claims <= 2 then 1.0 - else if claims <= 5 - then 1.25 - else 1.50 + else 1.25 ``` -The condition must be `bool`. The `then` and `else` branches must produce compatible types. +The condition must be `bool`. Both branches must produce assignable types. ### 5.5 Match Expressions -`match` dispatches on a subject value against a series of pattern arms: +#### Subject match ```axiom match industry { - "DRI-945" | "DRI-946" => "B", - _ => "Y", + "DRI-945" | "DRI-946" => "A", + _ => "B", } ``` -Subjectless match (condition-based): +#### Subjectless condition match ```axiom match { claims_count == 0 => 0.95, claims_count <= 2 => 1.00, - claims_count == 3 => 1.20, - _ => 1.50, + _ => 1.25, } ``` -Tuple match (multiple subjects): +#### Tuple match ```axiom -match (region, tier) { - ("north", "premium") => 1.2, - ("south", _) => 1.0, - _ => 0.9, +match (industry, employees) { + ("DRI-945", 1) => 0.85, + ("DRI-945", _) => 0.95, + _ => 1.00, } ``` -Variant match with destructuring: +#### Variant match ```axiom match cover { - rated { base_premium: p, limit: l } => p * l / 1000, - not_available { reason: r } => 0, + rated { premium: p } => p, + not_available { reason: _ } => 0, } ``` -#### Match over lists (`match ... in`) +#### Match over lists and tables (`match ... in`) -`match binding in list` iterates over a list, binding each element to a variable, and returns the first arm that matches. The wildcard arm serves as a "no element matched" fallback. +`match binding in iterable` searches a list or table in iteration order and returns +the first matching result. ```axiom match row in industry_config { - row.code == industry => row.pl_class, - _ => "", + row.code == industry => row.base_rate, + _ => 1.00, } ``` -This is the primary mechanism for querying table data. The arms are expression patterns evaluated with the binding in scope — any boolean condition that references the bound element. - Semantics: -1. For each element in the list, bind it to the variable and try each non-wildcard arm in order -2. The first element+arm combination that matches returns the arm's expression value -3. If no element matches any arm, the wildcard arm (if present) provides the default -```axiom -// Multi-condition lookup -match row in premium_bands { - turnover >= row.min && turnover < row.max => row.rate, - _ => 0, -} +1. Iterate the list or table in order. +2. For each element, bind it to the name. +3. Evaluate the non-wildcard arms top to bottom as boolean conditions. +4. Return the first arm body whose condition is `true`. +5. If no condition matches, evaluate the wildcard arm. -// Combining conditions: find matching row within a subset -match row in industry_config { - row.code in industries && row.pl_severity == worst => row.pl_class, - _ => "", -} -``` - -See §7 for all pattern forms. +The wildcard arm is required. ### 5.6 Expression Calls -Named expressions are called by name with named arguments: +Expression calls use one of two forms: -```axiom -BuildingsCover(exposure: exposure, limit: 500000) -``` +- positional arguments +- named arguments -Qualified calls into namespaces: +Calls may also mix the forms, with one restriction: positional arguments must come +before named arguments. + +Positional example: ```axiom -Industry.BuildingsFireClass(industry: exposure.industry) +BasePremium(exposure, 500000) ``` -#### Named argument shorthand - -When a variable name matches the parameter name, the `: value` can be omitted: +Named example: ```axiom -// These are equivalent: -Rate(exposure: exposure, limit: limit) -Rate(exposure, limit) +BasePremium(exposure: exposure, limit: 500000) ``` -#### Spread operator - -The spread operator `...` fills remaining unbound parameters by matching variable names in the caller's scope to parameter names in the callee: +Mixed example: ```axiom -Product(exposure: Exposure, claims: ClaimsHistory, pl_limit: number): ProductOutcome { - total_claims_loading = Claims.TotalLoading(...) - where total_claims_loading = Claims.TotalLoading( - number_of_claims: claims.number_of_claims, - total_claims_value: claims.total_claims_value, - ), - ... -} +Rate(industry, limit: 500000, employees: 3) ``` -Spread can be combined with explicit arguments — explicit arguments take precedence: +Qualified calls into namespaces: ```axiom -Rate(..., total_claims_loading: custom_value) +Industry.BaseRate(industry) ``` -The type checker validates that every spread-resolved binding has a compatible type with the target parameter. Missing parameters after spread resolution produce a type error. - ### 5.7 Variant Construction Variant values are constructed with their tag name: @@ -475,105 +515,74 @@ Variant values are constructed with their tag name: rated { key: "PL", name: "Public Liability", - base_premium: 500, - limit: 1000000, - excess: 250, + premium: 500, } ``` -Payload-less variant construction: +Payload-less alternatives are constructed with the tag alone: ```axiom -active // payload-less tag as identifier -declined // payload-less tag as identifier +declined ``` -Qualified construction for disambiguation: +Qualified construction may be used for disambiguation: ```axiom -CoverOutcome.not_available { reason: "zone_blocked" } +CoverOutcome.not_available { reason: "industry_blocked" } ``` -#### Field shorthand +Unqualified constructors resolve from expected type when available. Otherwise the +tag must be unique in scope or be qualified explicitly. -When a variable name matches the field name: +### 5.8 Record Literals ```axiom -// These are equivalent: -offered { covers: covers, subtotal: subtotal, total: total } -offered { covers, subtotal, total } -``` - -Shorthand and explicit fields can be mixed: - -```axiom -offered { covers, subtotal, total: round(raw_total, 2) } +{ + key: "PL", + name: "Public Liability", + premium: 500, +} ``` -### 5.8 Dict Literals +Field shorthand is allowed when a variable name matches the field name: ```axiom { - pl: PublicLiability.Rate(exposure, limit: pl_limit), - bc: BuildingsContents.Rate(exposure, bc, risks), - bi: BusinessInterruption.Rate(exposure, bi), + covers, + total, } ``` -Dict literals support the same field shorthand as variant construction. - ### 5.9 List Literals ```axiom [1, 2, 3] -["DRI-945", "DRI-946", "DRI-947"] -[rule1, rule2, rule3] +["A", "B"] +[cover_one, cover_two] ``` ### 5.10 Where Clauses -`where` introduces local bindings for intermediate values. Bindings are evaluated left-to-right and each binding can reference previous ones. +`where` introduces local bindings for intermediate values. ```axiom round(total, 2) where base = exposure.turnover / 1000 * rate, - adjusted = base * claims_loading * experience_factor, - total = adjusted * group_relativity + total = base * loading ``` -Where clauses keep expression bodies readable by naming intermediate steps without requiring separate named expressions for every calculation: - -```axiom -Product(exposure: Exposure, claims: ClaimsHistory): ProductOutcome { - offered { - covers, - total_gross_premium: round(subtotal, 2), - total_net_premium: round(subtotal * (1 - commission_rate), 2), - commission_rate: 0.35, - currency: "GBP", - } - where total_claims_loading = Claims.TotalLoading(...), - covers = { - pl: PublicLiability.Rate(exposure, limit: pl_limit, total_claims_loading), - bc: BuildingsContents.Rate(exposure, bc, risks, total_claims_loading), - }, - subtotal = max( - sum collect rated { base_premium: p } in covers => p, - MinimumPremium(exposure.industry), - ) -} -``` +Bindings are independent definitions inside the current scope. Their evaluation +order is determined by demand and data dependency, not by textual order alone. ### 5.11 Coercion ```axiom -"42" as number // string to number -42 as string // number to string +42 as string ``` ### 5.12 Parenthesized Expressions -Parentheses override operator precedence: +Parentheses override precedence: ```axiom (base + adjustment) * factor @@ -587,25 +596,24 @@ Parentheses override operator precedence: From lowest to highest precedence: -| Precedence | Operators | Associativity | Description | -|------------|-----------|---------------|-------------| -| 1 | `\|\|` | left | Logical OR | -| 2 | `&&` | left | Logical AND | -| 3 | `==`, `!=` | left | Equality | -| 4 | `<`, `>`, `<=`, `>=`, `in`, `not in` | left | Comparison and membership | -| 5 | `+`, `-` | left | Addition, subtraction | -| 6 | `*`, `/`, `%` | left | Multiplication, division, modulo | -| 7 | `**` | **right** | Exponentiation | +| Precedence | Operators | Associativity | +|------------|-----------|---------------| +| 1 | `||` | left | +| 2 | `&&` | left | +| 3 | `==`, `!=` | left | +| 4 | `<`, `>`, `<=`, `>=`, `in`, `not in` | left | +| 5 | `+`, `-` | left | +| 6 | `*`, `/` | left | ### 6.2 Unary Operators -| Operator | Operand | Result | Description | -|----------|---------|--------|-------------| -| `-` | `number` | `number` | Numeric negation | -| `not` | `bool` | `bool` | Logical negation | -| `!` | `bool` | `bool` | Logical negation (alias for `not`) | +| Operator | Operand | Result | +|----------|---------|--------| +| `-` | `number` | `number` | +| `not` | `bool` | `bool` | +| `!` | `bool` | `bool` | -`not` is the preferred form for readability. `!` is accepted as an alias. +`not` is the preferred spelling. `!` is an alias. ### 6.3 Arithmetic Operators @@ -614,277 +622,209 @@ From lowest to highest precedence: | `+` | `number` | `number` | `number` | | `-` | `number` | `number` | `number` | | `*` | `number` | `number` | `number` | -| `/` | `number` | `number` | `number` | -| `%` | `number` | `number` | `number` | -| `**` | `number` | `number` | `number` | +| `/` | `number` | `non_zero` | `number` | -Extensions may add operator rules for additional types (e.g., `money * number → money`). +Extensions may define additional operator rules for extension-defined types. ### 6.4 Comparison Operators | Operator | Operands | Result | |----------|----------|--------| -| `==` | any, any | `bool` | -| `!=` | any, any | `bool` | -| `<` | any, any | `bool` | -| `>` | any, any | `bool` | -| `<=` | any, any | `bool` | -| `>=` | any, any | `bool` | +| `==` | same comparable type | `bool` | +| `!=` | same comparable type | `bool` | +| `<` | same ordered type | `bool` | +| `>` | same ordered type | `bool` | +| `<=` | same ordered type | `bool` | +| `>=` | same ordered type | `bool` | ### 6.5 Logical Operators | Operator | Left | Right | Result | |----------|------|-------|--------| | `&&` | `bool` | `bool` | `bool` | -| `\|\|` | `bool` | `bool` | `bool` | +| `||` | `bool` | `bool` | `bool` | ### 6.6 Membership Operators -| Operator | Left | Right | Result | Description | -|----------|------|-------|--------|-------------| -| `in` | `T` | `list(T)` | `bool` | Element is in list | -| `not in` | `T` | `list(T)` | `bool` | Element is not in list | - -`not in` is a first-class operator, not sugar for `not (x in xs)`. It is parsed as a single two-token operator. - -### 6.7 Arrow Operator +| Operator | Left | Right | Result | +|----------|------|-------|--------| +| `in` | `T` | `list(T)` | `bool` | +| `not in` | `T` | `list(T)` | `bool` | -`=>` is used in match arms and collect bodies to separate patterns/conditions from result expressions. It is not a general-purpose operator. +`not in` is a first-class operator, not macro syntax. --- ## 7. Patterns -Patterns are used in `match` arms, `any`/`all` predicates, and `collect` forms. +Patterns are used in subject `match`, collection forms, and aggregate collection. ### 7.1 Wildcard Pattern -Matches any value without binding: - ```axiom _ => default_value ``` ### 7.2 Literal Patterns -Match by value equality: - ```axiom -42 => "exact number", -"brick" => 1.0, -true => "yes", +42 => "exact" +"brick" => 1.0 +true => "yes" ``` ### 7.3 Alternative Patterns -Match if any alternative matches: - ```axiom -"DRI-945" | "DRI-946" => "B", -1 | 2 | 3 => "low", +"DRI-945" | "DRI-946" => "A" +1 | 2 | 3 => "low" ``` ### 7.4 Range Patterns -Match numeric ranges with inclusive `[]` and exclusive `()` bounds: +Range patterns are part of the core language. ```axiom -[0..100] // 0 ≤ x ≤ 100 (inclusive both) -(0..100) // 0 < x < 100 (exclusive both) -[0..100) // 0 ≤ x < 100 (inclusive left, exclusive right) -(0..100] // 0 < x ≤ 100 (exclusive left, inclusive right) +[0..100] +(0..100) +[0..100) +(0..100] +[5..] +[..10] ``` -Open-ended ranges: - -```axiom -[5..] // x ≥ 5 -[..10] // x ≤ 10 -(0..) // x > 0 -``` +Range patterns operate over numeric subjects. ### 7.5 Variant Patterns -Match on variant tag and optionally destructure payload fields: - -```axiom -rated { base_premium: p, limit: l } => p + l, -not_available { reason: r } => r, -``` - -Field shorthand (bind to same name as field): - ```axiom -rated { base_premium, limit } => base_premium + limit, +rated { premium: p } => p +not_available { reason: r } => r +declined => 0 ``` -Wildcard field binding (match field but don't bind): +Field shorthand is allowed: ```axiom -rated { base_premium: _, limit: l } => l, +rated { premium } => premium ``` -Payload-less variant patterns: +Qualified variant patterns may be used for disambiguation: ```axiom -active => "active", -suspended => "on hold", -cancelled => "terminated", -``` - -Qualified variant patterns: - -```axiom -CoverOutcome.rated { base_premium: p } => p, +CoverOutcome.rated { premium: p } => p ``` ### 7.6 Tuple Patterns -Match against tuple subjects: - ```axiom -match (region, tier) { - ("north", "premium") => 1.2, - ("south", _) => 1.0, - (_, _) => 0.9, +match (industry, employees) { + ("DRI-945", 1) => 0.85, + (_, _) => 1.00, } ``` -### 7.7 Expression Patterns +### 7.7 Exhaustiveness -In subjectless match, arms use boolean expression patterns: +For variant subjects, match arms must be exhaustive. A wildcard arm satisfies +exhaustiveness for all remaining alternatives. ```axiom -match { - claims == 0 => 0.95, - claims <= 2 => 1.00, - _ => 1.50, -} -``` - -### 7.8 Exhaustiveness - -When matching on a variant type, the type checker verifies that all tags are covered. A wildcard `_` arm satisfies exhaustiveness for all remaining tags. - -```axiom -// Type error: non-exhaustive match — missing "referred" match outcome { offered { total: t } => t, declined => 0, + referred { reasons: _ } => 0, } ``` +For non-variant subjects, a wildcard arm is recommended but not required. + --- ## 8. Collection Forms -Axiom provides pattern-aware collection operations over lists. These are narrower than general higher-order functions and are designed for working with lists of variants. +Collection forms operate on `list(T)` values and on tables, which are lists of +records. -### 8.1 `any` — Existential Predicate +### 8.1 `any` - Existential Predicate -Returns `true` if at least one element matches the pattern: +Returns `true` if at least one element matches the pattern. ```axiom any referred in covers -any rated { base_premium: _ } in covers +any rated { premium: _ } in covers ``` Type: `bool` -### 8.2 `all` — Universal Predicate +### 8.2 `all` - Universal Predicate -Returns `true` if every element matches the pattern: +Returns `true` if every element matches the pattern. ```axiom all rated in covers -all ok { loading: _ } in rules ``` Type: `bool` -### 8.3 `collect` — Pattern Map +### 8.3 `collect` - Pattern Map -Evaluates the body for each matching element and returns a list of results: +Evaluates the body for each matching element and returns a list of results. ```axiom collect referred { reason: r } in covers => r -collect rated { base_premium: p } in covers => p * 1.1 +collect rated { premium: p } in covers => p ``` Type: `list(T)` where `T` is the body type. -### 8.4 Aggregate Collect +### 8.4 `collect` - Binding Map -Applies an aggregator function to the collected results. The aggregator is a registered intrinsic (e.g., `sum`, `product`, `max`, `min`). +The binding form transforms every element: ```axiom -product collect in rules { - ok { factor: f } => f, - _ => 1.0, -} - -sum collect in covers { - rated { base_premium: p } => p, - not_available => 0, -} +collect cover in covers => CoverAmount(cover) ``` -The arms must be exhaustive over the element type. Each arm body must produce a type compatible with the aggregator's input. +### 8.5 `collect` - Binding Filter Map -### 8.5 Collect Over Lists (`collect ... in`) - -The binding form of `collect` binds each element to a name and transforms or filters it. - -**Map form** — transform every element: - -```axiom -collect prop in exposure.properties => PropertyRating.Total(prop) -// → [45.27, 101.95, 163.82] -``` - -**Filter form** — collect only matching elements using arms: +The binding-arm form filters and transforms: ```axiom collect row in industry_config { - row.code in industries => row.pl_class, + row.min_premium > 0 => row.code, } -// → ["A", "B", "C"] ``` -Unlike `match ... in` (which returns the first match), `collect ... in` gathers all matches into a list. In the filter form, non-matching elements are skipped; a wildcard arm, if present, serves as a fallback for non-matching elements. +Non-matching elements are skipped. There is no implicit fallback value. -Type: `list(T)` where `T` is the body/arm body type. +### 8.6 Aggregate Collect -### 8.6 Aggregate Collect Over Lists +Aggregate collect applies a core aggregator to a collected list. -The aggregate form applies an aggregator to the collected results. +Core aggregators in v1: -**Map form** — aggregate every element: +- `sum` +- `product` -```axiom -sum collect prop in exposure.properties => PropertyRating.Total(prop) -// → 311.04 -``` - -**Filter form** — aggregate only matching elements: +Examples: ```axiom -max collect row in industry_config { - row.code in industries => row.deep_frying_rate, -} -// → 0.35 +sum collect rated { premium: p } in covers => p + +product collect factor in loadings => factor ``` -This is the primary mechanism for multi-row aggregation — finding the worst-case, total, or average across matching rows. +Aggregate collect preserves the iteration order of the source collection, though +the current core aggregators are order-insensitive. ### 8.7 Collection Form Typing -- The list operand must be `list(T)` for some `T`. +- The iterable operand must be `list(T)` or a table. - Patterns are checked against `T`. -- `any`/`all` return `bool`. -- `collect` returns `list(U)` where `U` is the body type. -- Aggregate collect returns the aggregator's return type (typically `number`). -- In binding forms (`collect row in list => ...` and `collect row in list { ... }`), the binding variable has the element type `T` and is available in the body or all arm expressions. +- `any` and `all` return `bool`. +- `collect` returns `list(U)`. +- Aggregate collect returns the aggregator result type. --- @@ -892,27 +832,22 @@ This is the primary mechanism for multi-row aggregation — finding the worst-ca Axiom v1 includes a small set of built-in functions. -### 9.1 Core Intrinsics - | Function | Signature | Description | |----------|-----------|-------------| -| `round(value, decimals)` | `(number, number) → number` | Round to `decimals` decimal places | -| `len(collection)` | `(list \| dict) → number` | Number of elements/entries | -| `flatten(nested)` | `(list(list(T))) → list(T)` | Flatten one nesting level | -| `sum(collection)` | `(list(number)) → number` | Sum all elements | -| `product(collection)` | `(list(number)) → number` | Multiply all elements | -| `max(a, b, ...)` | `(number, number, ...) → number` | Maximum value (variadic or single list) | -| `min(a, b, ...)` | `(number, number, ...) → number` | Minimum value (variadic or single list) | +| `round(value, places)` | `(number, number) -> number` | Round to `places` decimal places | +| `len(values)` | `(list(T)) -> number` | Number of elements | +| `flatten(nested)` | `(list(list(T))) -> list(T)` | Flatten one nesting level | +| `sum(values)` | `(list(number)) -> number` | Sum of all values | +| `product(values)` | `(list(number)) -> number` | Product of all values | -### 9.2 Aggregators +Semantics: -`sum`, `product`, `max`, and `min` are also usable as aggregator names in aggregate collect expressions: +- `round(value, places)` truncates `places` toward zero before rounding. +- `sum([])` is `0`. +- `product([])` is `1`. -```axiom -sum collect in items { rated { premium: p } => p, _ => 0 } -product collect in rules { ok { factor: f } => f, _ => 1 } -max collect in options { available { rate: r } => r, _ => 0 } -``` +Additional intrinsics may be provided by extensions, but they must obey the same +determinism and totality requirements as the core language. --- @@ -920,803 +855,585 @@ max collect in options { available { rate: r } => r, _ => 0 } ### 10.1 Inference Rules -The type checker infers types bottom-up: +The type checker infers types bottom-up. | Expression | Inferred Type | |------------|---------------| -| `42`, `3.14` | `number` | +| `42` | `non_zero` | +| `0` | `number` | | `"hello"` | `string` | -| `true`, `false` | `bool` | -| `[1, 2, 3]` | `list(number)` | -| `{ a: 1, b: "x" }` | `dict` with shape `{ a: number, b: string }` | +| `true` | `bool` | +| `[1, 2, 3]` | `list(non_zero)` | +| `{ a: 1, b: "x" }` | inline record shape `{ a: non_zero, b: string }` | | `identifier` | declared type from scope | -| `object.property` | property type from object shape | -| `object[index]` | element/value type | +| `object.field` | field type from record shape | | `expr as type` | target type | -| `left OP right` | operator return type | -| `not expr` / `!expr` | `bool` | -| `-expr` | `number` | +| `left OP right` | operator result type | | `if/then/else` | common branch type | | `match` | common arm type | -| `Name(args)` | return type of named expression | +| `Name(args)` | declared return type of named expression | | `tag { fields }` | resolved variant type | | `any P in xs` | `bool` | -| `all P in xs` | `bool` | -| `collect P in xs => body` | `list(T)` where `T` is body type | -| `agg collect in xs { ... }` | aggregator return type | +| `collect P in xs => body` | `list(T)` | +| `agg collect ...` | aggregator return type | | `expr where bindings` | type of `expr` | ### 10.2 Variant Resolution Variant tags are resolved in this order: -1. **Contextual**: from the expected type (return type annotation, list element type, etc.) -2. **Qualified**: explicit `TypeName.tag` or `Namespace.tag` -3. **First-match**: search all declared types for a unique match - -If a tag appears in multiple types and is not qualified, the type checker reports an ambiguity error. +1. Expected type from context +2. Explicit qualification +3. Unique visible tag -### 10.3 Conditional Typing +If a tag is ambiguous, qualification is required. -Both branches of `if/then/else` must produce compatible types. When the return type is annotated as a variant type, branches may produce different tags of that variant. +### 10.3 Call Checking -### 10.4 Match Exhaustiveness +For each call, the type checker validates: -For variant subjects, the type checker verifies all tags are covered. A wildcard `_` arm satisfies any uncovered tags. Missing coverage produces a diagnostic listing the uncovered tags. +- the callee exists +- the argument ordering is valid +- positional arity or named parameter completeness +- argument types are assignable to parameter types +- the body type is assignable to the declared return type -For non-variant subjects (numbers, strings), no exhaustiveness check is performed — a wildcard arm is recommended. +### 10.4 Table Checking -### 10.5 Expression Call Checking +For each table declaration, the checker validates: -For each expression call, the type checker validates: +- the row type is well formed +- all table field references are valid against the row schema +- `match row in table` and `collect row in table` bind rows with the declared row type -- All required parameters are provided (by name, shorthand, or spread) -- No unknown parameter names -- Argument types are assignable to parameter types -- Spread-resolved bindings have compatible types -- Return type (if annotated) is satisfied by the body - -### 10.6 Soundness Guarantees - -The type checker enforces: - -- Operator type validity -- Coercion validity -- Member access validity (field exists on type) -- Index access validity -- Match exhaustiveness (for variant subjects) -- Match arm type consistency -- Expression call argument validation (arity, names, types) -- Variant constructor validity (tag exists, fields correct, types match) -- No unresolved symbols -- No duplicate expression or type names - ---- - -## 11. Evaluation - -### 11.1 Direct AST Evaluation - -The evaluator walks AST nodes directly. There is no separate compiled intermediate representation in v1. - -### 11.2 Runtime Representation - -**Records and dicts** are plain associative structures: - -```json -{ "industry": "DRI-945", "turnover": 500000 } -``` +### 10.5 Division Safety -**Variants** use a reserved `_tag` field: +The checker rejects division when the divisor is not statically proven to be +`non_zero`. -```json -{ - "_tag": "rated", - "key": "PL", - "name": "Public Liability", - "base_premium": 500, - "limit": 1000000, - "excess": 250 +```axiom +Ratio(a: number, b: number): number { + a / b } ``` -**Payload-less variants**: +The expression above is a type error because `b` is only `number`. -```json -{ "_tag": "active" } +```axiom +Ratio(a: number, b: non_zero): number { + a / b +} ``` -The `_tag` field is reserved. Authors may not declare payload fields named `_tag`. +This is valid. -### 11.3 Scope +### 10.6 Recursion Detection -Each expression call creates a new scope with parameters bound from arguments. `where` bindings extend the current scope for the duration of the `where` expression. Namespace symbols are available via qualified access. +The checker builds the call graph of named expressions and rejects cyclic +dependencies. Mutual recursion and self-recursion are type errors in v1. -### 11.4 Evaluation Order +### 10.7 Soundness Checks -- `if/then/else`: condition first, then only the taken branch -- `match`: subject first, then arms top-to-bottom until a match -- `where`: bindings left-to-right, then the body -- Operators: left-to-right (except `**` which is right-to-left) -- Expression calls: arguments evaluated, then body in new scope -- `&&` / `||`: short-circuit evaluation +The checker enforces: -### 11.5 Match Dispatch - -- **Literal arms**: match by value equality -- **Range arms**: match by numeric range inclusion -- **Variant arms**: dispatch on the runtime `_tag` field; matched bindings introduced into arm scope -- **Wildcard arms**: match any value -- **Expression arms** (subjectless match): evaluate the expression as boolean; first truthy arm wins -- **Alternative arms**: match if any sub-pattern matches - -### 11.6 Collection Form Evaluation - -- `any P in xs` — iterate, return `true` on first match -- `all P in xs` — iterate, return `false` on first non-match -- `collect P in xs => body` — iterate, evaluate body for each match, collect into list -- `agg collect in xs { arms }` — iterate, evaluate matching arm body for each element, apply aggregator to resulting list +- operator type validity +- coercion validity +- member access validity +- match exhaustiveness for variant subjects +- match arm type consistency +- collection-form typing +- table row access validity +- division safety +- recursion absence +- unresolved symbol detection +- duplicate declaration detection --- -## 12. Grammar - -```ebnf -program = { declaration } ; -declaration = type_decl | namespace_decl | expr_decl ; - -(* --- Type declarations --- *) - -type_decl = "type" UPPER_IDENT "=" ( record_shape | variant_alts ) ; -record_shape = "{" field_decl { "," field_decl } [ "," ] "}" ; -variant_alts = variant_alt { "|" variant_alt } ; -variant_alt = LOWER_IDENT [ "{" field_decl { "," field_decl } [ "," ] "}" ] ; -field_decl = LOWER_IDENT ":" type_expr ; - -(* --- Namespace declarations --- *) - -namespace_decl = "namespace" UPPER_IDENT "{" { ns_member } "}" ; -ns_member = type_decl | expr_decl | symbol_decl ; -symbol_decl = LOWER_IDENT ":" type_expr "=" expression ; - -(* --- Expression declarations --- *) - -expr_decl = UPPER_IDENT "(" [ param_list ] ")" [ ":" type_expr ] - "{" expression "}" ; -param_list = param { "," param } ; -param = LOWER_IDENT ":" ( type_expr | record_shape ) ; - -(* --- Type expressions --- *) - -type_expr = TYPE_KEYWORD [ "(" type_args ")" ] | UPPER_IDENT ; -type_args = expression { "," expression } ; -TYPE_KEYWORD = "number" | "string" | "bool" | "list" | "dict" ; - -(* --- Expressions --- *) - -expression = where_expr ; - -where_expr = or_expr [ "where" binding { "," binding } ] ; -binding = LOWER_IDENT "=" expression ; - -or_expr = and_expr { "||" and_expr } ; -and_expr = equality_expr { "&&" equality_expr } ; -equality_expr = comparison_expr { ( "==" | "!=" ) comparison_expr } ; -comparison_expr = additive_expr { ( "<" | ">" | "<=" | ">=" | "in" - | "not" "in" ) additive_expr } ; -additive_expr = multiplicative_expr { ( "+" | "-" ) multiplicative_expr } ; -multiplicative_expr = power_expr { ( "*" | "/" | "%" ) power_expr } ; -power_expr = unary_expr [ "**" power_expr ] ; -unary_expr = ( "not" | "!" | "-" ) unary_expr | postfix_expr ; -postfix_expr = primary { "." LOWER_IDENT - | "[" expression "]" - | "as" type_expr } ; - -primary = if_expr - | match_expr - | aggregate_collect - | collect_expr - | any_expr - | all_expr - | call_or_variant_ctor - | list_literal - | dict_literal - | NUMBER | STRING | BOOL - | LOWER_IDENT | UPPER_IDENT - | "(" expression ")" ; - -(* --- Control flow --- *) - -if_expr = "if" expression "then" expression - { "else" "if" expression "then" expression } - "else" expression ; - -match_expr = "match" [ match_subject ] "{" match_arm { "," match_arm } - [ "," ] "}" ; -match_subject = "(" expression { "," expression } ")" | expression ; -match_arm = pattern "=>" expression ; - -(* --- Collection forms --- *) - -any_expr = "any" pattern "in" expression ; -all_expr = "all" pattern "in" expression ; -collect_expr = "collect" pattern "in" expression "=>" expression ; -aggregate_collect = LOWER_IDENT "collect" "in" expression - "{" collect_arm { "," collect_arm } [ "," ] "}" ; -collect_arm = pattern "=>" expression ; +## 11. Evaluation -(* --- Calls and construction --- *) +### 11.1 Execution Model -call_or_variant_ctor = qualified_upper "(" [ arg_list ] [ "..." ] ")" - | LOWER_IDENT "{" [ entry_list ] "}" - | qualified_upper "." LOWER_IDENT "{" [ entry_list ] "}" ; -qualified_upper = UPPER_IDENT { "." UPPER_IDENT } ; -arg_list = arg { "," arg } ; -arg = LOWER_IDENT ":" expression | expression ; -entry_list = entry { "," entry } ; -entry = LOWER_IDENT ":" expression | LOWER_IDENT ; +Axiom v1 uses lazy evaluation with memoization. -list_literal = "[" [ expression { "," expression } ] [ "," ] "]" ; -dict_literal = "{" [ entry_list ] [ "," ] "}" ; +- Expression arguments are evaluated on first use. +- `where` bindings are evaluated on first use. +- Each value is computed at most once per scope. +- Each expression call creates a fresh child scope and memo table. -(* --- Patterns --- *) +Because Axiom is pure, lazy evaluation changes performance only, not meaning. -pattern = alt_pattern ; -alt_pattern = single_pattern { "|" single_pattern } ; -single_pattern = wildcard_pat | range_pat | variant_pat | tuple_pat - | literal_pat | expr_pat ; -wildcard_pat = "_" ; -literal_pat = NUMBER | STRING | BOOL ; -range_pat = ( "[" | "(" ) [ NUMBER ] ".." [ NUMBER ] ( "]" | ")" ) ; -variant_pat = [ qualified_upper "." ] LOWER_IDENT - [ "{" pat_binding { "," pat_binding } [ "," ] "}" ] ; -pat_binding = LOWER_IDENT [ ":" ( LOWER_IDENT | "_" ) ] ; -tuple_pat = "(" pattern "," pattern { "," pattern } ")" ; -expr_pat = expression ; +### 11.2 Runtime Representation -(* --- Lexical --- *) +Records are plain associative structures: -UPPER_IDENT = [A-Z] [a-zA-Z0-9_]* ; -LOWER_IDENT = [a-z_] [a-zA-Z0-9_]* ; -NUMBER = [0-9]+ [ "." [0-9]+ ] ; -STRING = '"' ( [^"\\] | '\\' . )* '"' ; -BOOL = "true" | "false" ; -COMMENT = "//" [^\n]* ; +```json +{ "industry": "DRI-945", "turnover": "500000" } ``` -### 12.1 Keywords +Variants use a reserved `_tag` field: -``` -type namespace if then else match not in as -any all collect where true false +```json +{ + "_tag": "rated", + "key": "PL", + "premium": "500" +} ``` -### 12.2 Reserved +Payload-less variants are represented as: -``` -_tag // reserved field name (variant tag marker) -_ // wildcard pattern +```json +{ "_tag": "declined" } ``` ---- +Authors may not declare a payload field named `_tag`. -## 13. Diagnostics +### 11.3 Evaluation Order -All pipeline stages (parse, type check, lint, validate) produce diagnostics with a uniform structure: +- `if/then/else`: evaluate the condition, then the taken branch only +- subject `match`: evaluate the subject, then arms top to bottom +- subjectless `match`: evaluate arms top to bottom until a condition succeeds +- `match binding in iterable`: iterate in collection order, then arm order +- `where`: evaluate bindings on demand +- `&&` and `||`: short-circuit +- collection forms: iterate in collection order -- **severity**: `error`, `warning`, or `info` -- **code**: stable dotted identifier (e.g., `type.unknown_tag`, `parse.unexpected_token`) -- **message**: human-readable description -- **location**: line, column, offset, length +### 11.4 Table Evaluation -### 13.1 Diagnostic Code Categories +Tables are evaluated as immutable ordered lists of rows. -| Prefix | Stage | Examples | -|--------|-------|----------| -| `parse.*` | Parser | `parse.unexpected_token`, `parse.unterminated_string` | -| `type.*` | Type checker | `type.unknown_tag`, `type.missing_field`, `type.argument_mismatch` | -| `lint.*` | Linter | `lint.unused_expression`, `lint.unreachable_arm` | -| `validation.*` | Input validation | `validation.missing_field`, `validation.type_mismatch` | - -### 13.2 Error Quality - -Diagnostics should: - -- Name expected and actual types concretely: `expected number, got string` -- For unknown variant tags, suggest the nearest valid tag -- For unknown fields, suggest the nearest valid field name -- For expression call errors, show the expected parameter signature -- For type mismatches in arguments, identify which parameter is wrong - -### 13.3 Parser Recovery - -The parser should recover at declaration boundaries and produce partial ASTs where possible, so the type checker can report additional errors on valid portions. +- Table artifacts are loaded and validated before any expression is evaluated. +- Row order is preserved exactly as declared by the artifact. +- Evaluation never mutates a table or derives side effects from reading it. --- -## 14. Open Questions +## 12. Diagnostics -The following areas are acknowledged as important but not yet fully designed. Each will be addressed as a follow-up to this specification. +All pipeline stages produce diagnostics with a uniform structure: -### 14.1 ~~Boundary Functions and~~ External Data — RESOLVED +- `severity`: `error`, `warning`, or `info` +- `code`: stable dotted identifier +- `message`: human-readable description +- `location`: line, column, offset, length -**Resolution**: External data integrates via **table declarations** (§3.4) and **match/collect over lists** (§5.5, §8.5–8.6), not via boundary functions or plugins. +### 12.1 Diagnostic Categories -**Key design decisions**: +| Prefix | Stage | +|--------|-------| +| `parse.*` | Parser | +| `type.*` | Type checker | +| `validation.*` | Input and artifact validation | +| `extension.*` | Extension loading and overlap validation | -1. **Tables are core language constructs**, not an escape hatch to external systems. A `table` declaration defines a typed, immutable list of records. The data comes from a companion artifact (CSV, etc.) bundled with the program version. +### 12.2 Error Quality -2. **The language is declarative about data access**. `match row in table { condition => value }` says WHAT to find; the runtime decides HOW (CSV scan, SQLite index, hash lookup). Same program, same results regardless of backing store. +Diagnostics should: -3. **No boundary functions needed for data lookup**. The original thinking assumed external data required a plugin system or callable escape hatch. Instead, tables make external data a first-class, pure, type-checked part of the language. +- name expected and actual types concretely +- identify the precise parameter, field, or arm involved +- list missing variant alternatives for non-exhaustive matches +- identify the specific divisor expression that is not proven `non_zero` +- identify the specific table and row field involved in artifact validation failures -4. **Failure model**: Tables are validated at load time against the declared schema. If the artifact doesn't match the schema, the program fails to load — not at evaluation time. The wildcard arm in `match ... in` handles "no row matched" as a value, not an error. +--- -5. **Multi-row queries** use `collect row in table { ... }` to gather all matching rows, composable with existing aggregators (`max`, `sum`, etc.). +## 13. Grammar -**Prototyped and validated** in the playground with a hospitality insurance product: single table replaces 12 source declarations, CSV artifact loading, single-industry and multi-industry lookups all verified. +```ebnf +program = { declaration } ; +declaration = type_decl | namespace_decl | table_decl | expr_decl ; -### 14.2 Money Type — RESOLVED +(* --- Declarations --- *) -Money is an **extension type** that plugs into the language via custom types, operator overloading, and coercion rules (see §14.6). It is not part of the core language, but v1 defines the syntax and semantics that money extensions must conform to. +type_decl = "type" UPPER_IDENT "=" ( record_shape | variant_alts ) ; +record_shape = "{" field_decl { "," field_decl } [ "," ] "}" ; +variant_alts = variant_alt { "|" variant_alt } ; +variant_alt = LOWER_IDENT [ record_shape ] ; +field_decl = LOWER_IDENT ":" type_expr ; -#### Literal Syntax +namespace_decl = "namespace" UPPER_IDENT "{" { namespace_member } "}" ; +namespace_member = type_decl | expr_decl ; -Money literals use a currency prefix (symbol or ISO 4217 code) followed directly by a decimal number: +table_decl = "table" LOWER_IDENT ":" "list" "(" table_row_type ")" ; +table_row_type = record_shape | qualified_upper ; -```axiom -£100 // money(GBP) -£1234.56 // money(GBP) -€50.25 // money(EUR) -$200 // money(USD) -GBP100 // money(GBP) — 3-letter ISO code form -USD1500.00 // money(USD) -JPY10000 // money(JPY) -``` +expr_decl = UPPER_IDENT "(" [ param_list ] ")" ":" type_expr + "{" expression "}" ; +param_list = param { "," param } ; +param = LOWER_IDENT ":" type_expr ; -**Predefined symbol mapping:** +(* --- Type expressions --- *) -| Symbol | Currency | -|--------|----------| -| `£` | GBP | -| `€` | EUR | -| `$` | USD | -| `¥` | JPY | +type_expr = primitive_type + | list_type + | record_shape + | qualified_upper + | extension_type ; -All other currencies use the 3-letter ISO 4217 code prefix (e.g., `AUD250`, `CHF100.50`). +primitive_type = "number" | "non_zero" | "string" | "bool" ; +list_type = "list" "(" type_expr ")" ; +extension_type = LOWER_IDENT "(" type_arg { "," type_arg } ")" ; +type_arg = qualified_upper | LOWER_IDENT | type_expr ; -#### Type +qualified_upper = UPPER_IDENT { "." UPPER_IDENT } ; -`money(CURRENCY)` is a parameterized type. The currency is part of the type — `money(GBP)` and `money(USD)` are distinct types. +(* --- Expressions --- *) -```axiom -BasePremium(turnover: number, rate: number): money(GBP) { - £100 + turnover * rate // type error: number * number → number, not money -} -``` +expression = where_expr ; +where_expr = or_expr [ "where" binding { "," binding } ] ; +binding = LOWER_IDENT "=" expression ; + +or_expr = and_expr { "||" and_expr } ; +and_expr = equality_expr { "&&" equality_expr } ; +equality_expr = comparison_expr { ( "==" | "!=" ) comparison_expr } ; +comparison_expr = additive_expr + { ( "<" | ">" | "<=" | ">=" | "in" | "not" "in" ) + additive_expr } ; +additive_expr = multiplicative_expr { ( "+" | "-" ) multiplicative_expr } ; +multiplicative_expr = unary_expr { ( "*" | "/" ) unary_expr } ; +unary_expr = ( "not" | "!" | "-" ) unary_expr | postfix_expr ; +postfix_expr = primary { "." LOWER_IDENT | "as" type_expr } ; + +primary = if_expr + | match_expr + | aggregate_collect_expr + | collect_expr + | any_expr + | all_expr + | call_expr + | variant_ctor + | list_literal + | record_literal + | NUMBER + | STRING + | BOOL + | LOWER_IDENT + | qualified_upper + | "(" expression ")" ; -#### Arithmetic Rules +(* --- Control flow --- *) -| Expression | Result | Notes | -|------------|--------|-------| -| `money(C) + money(C)` | `money(C)` | Same currency required | -| `money(C) - money(C)` | `money(C)` | Same currency required | -| `money(C) * number` | `money(C)` | Scaling | -| `number * money(C)` | `money(C)` | Scaling (commutative) | -| `money(C) / number` | `money(C)` | Division by scalar | -| `money(C) / money(C)` | `number` | Ratio | -| `money(C1) + money(C2)` | **type error** | Cross-currency | -| `money(C) + number` | **type error** | Cannot mix money and number | +if_expr = "if" expression "then" expression + { "else" "if" expression "then" expression } + "else" expression ; -Comparison operators (`==`, `!=`, `<`, `>`, `<=`, `>=`) work between values of the same `money(C)` type and return `bool`. +match_expr = subject_match | condition_match | binding_match ; +subject_match = "match" match_subject "{" pattern_arm { "," pattern_arm } [ "," ] "}" ; +condition_match = "match" "{" condition_arm { "," condition_arm } [ "," ] "}" ; +binding_match = "match" LOWER_IDENT "in" expression + "{" condition_arm { "," condition_arm } [ "," ] "}" ; -#### Coercion +match_subject = expression | "(" expression { "," expression } ")" ; +pattern_arm = pattern "=>" expression ; +condition_arm = ( expression | "_" ) "=>" expression ; -```axiom -"100.50" as money(GBP) // string → money -150 as money(GBP) // number → money -``` +(* --- Collection forms --- *) -#### Precision +any_expr = "any" pattern "in" expression ; +all_expr = "all" pattern "in" expression ; -Money operations use arbitrary-precision decimal arithmetic (e.g., Brick\Money in PHP). Intermediate calculations preserve full precision. Rounding is explicit: +collect_expr = pattern_collect + | binding_collect + | binding_arm_collect ; -```axiom -round(£100 / 3, 2) // → £33.33 -``` +pattern_collect = "collect" pattern "in" expression "=>" expression ; +binding_collect = "collect" LOWER_IDENT "in" expression "=>" expression ; +binding_arm_collect = "collect" LOWER_IDENT "in" expression + "{" condition_arm { "," condition_arm } [ "," ] "}" ; -Currency-specific precision (e.g., 2 decimal places for GBP, 0 for JPY) is enforced by the runtime, not the language. +aggregate_collect_expr = + aggregator "collect" pattern "in" expression "=>" expression + | aggregator "collect" LOWER_IDENT "in" expression "=>" expression + | aggregator "collect" LOWER_IDENT "in" expression + "{" condition_arm { "," condition_arm } [ "," ] "}" ; -#### Extension Mechanism +aggregator = "sum" | "product" ; -A money extension registers: -1. **Type constructor** — `money(CURRENCY)` with currency validation -2. **Operator overloader** — arithmetic and comparison rules for money operands -3. **Coercion rules** — `string → money`, `number → money` conversion -4. **Literal tokenizer** — currency symbol/code recognition in the lexer +(* --- Calls and construction --- *) -The playground treats money as `number` for simplicity. A production implementation uses the money extension with Brick\Money (or equivalent) for precision and currency safety. +call_expr = qualified_call "(" [ arg_list ] ")" ; +qualified_call = UPPER_IDENT { "." UPPER_IDENT } ; +arg_list = positional_then_named | named_args ; +positional_then_named = + positional_args [ "," named_args ] ; +positional_args = expression { "," expression } ; +named_args = named_arg { "," named_arg } ; +named_arg = LOWER_IDENT ":" expression ; -### 14.3 Numeric Precision — RESOLVED +variant_ctor = [ qualified_upper "." ] LOWER_IDENT [ record_shape_expr ] ; +record_shape_expr = "{" [ record_entry { "," record_entry } [ "," ] ] "}" ; +record_entry = LOWER_IDENT ":" expression | LOWER_IDENT ; -**Resolution**: `number` is arbitrary-precision decimal. IEEE 754 floats are not conformant. See §19 for full specification. +list_literal = "[" [ expression { "," expression } ] [ "," ] "]" ; +record_literal = "{" [ record_entry { "," record_entry } [ "," ] ] "}" ; -**Key decisions**: +(* --- Patterns --- *) -1. **Exact decimal representation**. All `number` values are stored and computed as arbitrary-precision decimals. Literals like `0.1` are parsed as exact decimal values, not float approximations. `0.1 + 0.2 == 0.3` must hold. +pattern = alt_pattern ; +alt_pattern = single_pattern { "|" single_pattern } ; +single_pattern = wildcard_pat + | range_pat + | variant_pat + | tuple_pat + | literal_pat ; + +wildcard_pat = "_" ; +literal_pat = NUMBER | STRING | BOOL ; +range_pat = ( "[" | "(" ) [ NUMBER ] ".." [ NUMBER ] ( "]" | ")" ) ; +variant_pat = [ qualified_upper "." ] LOWER_IDENT [ pattern_record ] ; +pattern_record = "{" [ pattern_field { "," pattern_field } [ "," ] ] "}" ; +pattern_field = LOWER_IDENT [ ":" ( LOWER_IDENT | "_" ) ] ; +tuple_pat = "(" pattern "," pattern { "," pattern } ")" ; -2. **Arithmetic precision**. All operators (`+`, `-`, `*`, `/`, `%`, `**`) operate on exact decimals. Division produces exact results up to implementation-defined precision (recommended minimum: 20 significant digits). Explicit `round()` is required when a specific precision is needed. +(* --- Lexical --- *) -3. **Comparison is exact**. No epsilon tolerance — decimal comparison is bitwise on the decimal representation. This eliminates an entire class of subtle bugs in financial computation. +UPPER_IDENT = [A-Z] [a-zA-Z0-9_]* ; +LOWER_IDENT = [a-z_] [a-zA-Z0-9_]* ; +NUMBER = [0-9]+ [ "." [0-9]+ ] ; +STRING = '"' ( [^"\\] | '\\' . )* '"' ; +BOOL = "true" | "false" ; +COMMENT = "//" [^\n]* ; +``` -4. **Coercion preserves precision**. `"1.005" as number` produces exact `1.005`, not a float approximation. JSON serialization uses string-encoded decimals to avoid JSON float precision loss. +### 13.1 Keywords -5. **Implementation requirements**. Conformant implementations must use an arbitrary-precision decimal library: `bcmath` or `brick/math` in PHP, `BigDecimal` in Java, `decimal.js` in JavaScript, `decimal` module in Python. +```text +type namespace table +if then else +match in +any all collect +where as +not true false +sum product +``` -6. **Playground exception**. The playground uses JavaScript floats as a pragmatic simplification for prototyping. It is explicitly non-conformant on precision — this is acceptable for syntax and type-system validation, but not for production use. +### 13.2 Reserved -### 14.4 Evaluation Model — Lazy vs Eager — RESOLVED +```text +_tag +_ +``` -**Resolution**: Lazy evaluation with memoization. Parameters and `where` bindings are evaluated on first reference, not at definition time. Each value is computed at most once and cached. +--- -**Key decisions**: +## 14. Example -1. **Expression arguments are lazy**. Parameters are evaluated on first reference, not at call time. If a branch never touches a parameter, it's never computed. A product that declines early skips all cover rating computations. +The following example uses only core v1 features. -2. **`where` bindings are lazy**. In `body where a = ..., b = ..., c = ...`, each binding is only evaluated when first demanded by `body` or by another binding. Source order does not determine evaluation order — only data dependencies do. +```axiom +type Exposure = { + industry: string, + turnover: number, +} -3. **Memoized**. Each parameter and `where` binding is evaluated at most once per scope. First access computes and caches; subsequent accesses return the cached value. +type CoverOutcome = + rated { + key: string, + name: string, + premium: number, + } + | not_available { reason: string } -4. **Fresh memo table per call**. Each named expression call creates a child scope with a fresh memo table. There is no sharing of memoized values across expression calls. +type ProductOutcome = + offered { + covers: list(CoverOutcome), + total: number, + } + | referred { reasons: list(string) } -5. **Safety guarantee**. Lazy evaluation is safe because Axiom is pure — no side effects, no mutation, no I/O. The result of evaluating an expression is the same regardless of evaluation order. The only observable difference is performance. +table industry_config: list({ + code: string, + base_rate: number, + minimum_premium: number, +}) -6. **Type checker validates all paths**. The type checker validates ALL branches and ALL expressions statically, regardless of whether they would be reached at runtime. Errors in unreached code are caught at check time, not hidden by lazy evaluation. +BaseRate(industry: string): number { + match row in industry_config { + row.code == industry => row.base_rate, + _ => 1.00, + } +} -**Playground exception**: The playground uses eager evaluation for implementation simplicity. This produces identical results (purity guarantees this) but may compute unnecessary values. +MinimumPremium(industry: string): number { + match row in industry_config { + row.code == industry => row.minimum_premium, + _ => 0, + } +} -### 14.5 Error Model — RESOLVED +LiabilityCover(exposure: Exposure): CoverOutcome { + if exposure.turnover == 0 + then not_available { reason: "turnover_zero" } + else rated { + key: "PL", + name: "Public Liability", + premium: exposure.turnover / 1000 * BaseRate(exposure.industry), + } +} -**Resolution**: If the type checker passes and input validation passes, evaluation cannot fail. The error model is **statically total** — every potential runtime error is either prevented by the type system or made safe by design. +Product(exposure: Exposure): ProductOutcome { + if any not_available in covers + then referred { + reasons: collect not_available { reason } in covers => reason, + } + else offered { + covers, + total: sum collect rated { premium } in covers => premium, + } + where covers = [ + LiabilityCover(exposure), + ] +} +``` -**Key decisions**: +--- -1. **Division by zero — prevented by refined number types**. The type system includes refined subtypes of `number`: `positive` (> 0), `non_negative` (>= 0), and `non_zero` (!= 0). Division requires the divisor to be `non_zero` or `positive`. If the divisor is typed as plain `number`, the type checker rejects it. See §6.1 in the revised spec for the full refinement type system. +## 15. Extensions -2. **Non-exhaustive match — type error**. The type checker requires match arms to be exhaustive: all variant tags covered, or a wildcard `_` present. Non-exhaustive match is a type error, not a runtime error. +Extensions are part of Axiom v1, but their scope is intentionally narrow. -3. **Coercion failure — total by design**. `"abc" as number` returns `0`. Coercion is explicitly requested by the author and always succeeds. Input data is validated at load time, so runtime coercions typically operate on known-good data. +### 15.1 What Extensions May Add -4. **Index out of bounds — total by design**. `list[n]` where `n` is out of range returns `null`. The type checker warns on unguarded index access. In practice, Axiom programs rarely use direct indexing — `collect`, `match ... in`, `any`, and `all` iterate safely. +An extension may add: -5. **Missing input fields — rejected at input validation**. Input data is validated against declared parameter shapes before evaluation begins. +- custom literal forms +- custom types +- operator rules for those types +- intrinsic overloads for those types +- total coercions involving those types -6. **Table schema mismatch — rejected at load time**. Table data is validated against the declared schema when the program is loaded. +An extension may not add: -**Refined number types** are the key design addition. They allow the type system to prove division safety, and they align naturally with the insurance domain where divisors are almost always inherently positive (counts, rates, sums insured). See §6.1 for the subtype hierarchy and §12.2 for inference and narrowing rules. +- new keywords +- new control-flow forms +- new pattern syntax +- mutable state +- side effects +- external data access -### 14.6 Extension/Plugin System — RESOLVED +### 15.2 Extension Contract -**Resolution**: A plugin is a bundle of hooks across three pipeline stages: **lexer**, **checker**, and **evaluator**. The hooks are defined as abstract contracts (not tied to a specific language) and follow a "first plugin wins, or fall through to defaults" dispatch model. Prototyped and validated in the playground with the `axiom-money` plugin. +An extension participates in three stages: -#### Plugin Structure +- literal recognition +- type checking +- evaluation -A plugin provides a name and optional hooks for each pipeline stage: +Conceptually: -``` -Plugin +```text +Extension name: string - lexer?: LexerHooks - checker?: CheckerHooks - evaluator?: EvaluatorHooks -``` - -All hooks are optional. A plugin may provide hooks for any combination of stages — e.g., a money plugin provides all three, while a plugin that only adds intrinsic functions might only provide evaluator hooks. - -#### Lexer Hooks - -The lexer hook allows a plugin to recognise custom literal syntax in the source text. - -``` -LexerHooks - tryTokenize(source: string, position: int) -> PluginToken | null - -PluginToken - tag: string // token identifier, e.g. "money" - value: string // display text, e.g. "£100.50" - payload: any // structured data carried to AST and evaluator - length: int // characters consumed from source + lexer?: literal hooks + checker?: type hooks + evaluator?: runtime hooks ``` -The lexer tries each plugin's `tryTokenize` at each position **before** the core tokenizer. If a plugin returns a token, it's emitted as a `PluginLiteral` and the lexer advances by `length`. If no plugin matches, the core tokenizer handles the position. +The precise host-language API is implementation-defined, but all conforming +implementations must preserve the same source-level semantics. -**Example**: The money plugin's lexer hook recognises `£100.50` (symbol prefix) and `GBP100.50` (ISO code prefix), returning a token with tag `"money"` and a structured payload containing the amount and currency. +### 15.3 Non-Overlap Rule -#### Checker Hooks +Extension meaning may not depend on registration order. -The checker hooks allow a plugin to participate in type inference and type checking. - -``` -CheckerHooks - inferLiteralType?(tag: string, payload: any) -> TypeSig | null - checkBinaryOp?(op: string, left: TypeSig, right: TypeSig) -> TypeSig | TypeError | null - checkCall?(name: string, argTypes: TypeSig[]) -> TypeSig | null -``` +At program load time: -**`inferLiteralType`** — Given a plugin literal's tag and payload, return its type. The type checker calls this for every `PluginLiteral` node. Returns `null` to indicate the plugin doesn't handle this tag. +- if two extensions claim the same literal family, load fails +- if two extensions claim the same type constructor, load fails +- if two extensions define overlapping operator or intrinsic behavior for the same + operand types, load fails -**`checkBinaryOp`** — Given an operator and the inferred types of both operands, return: -- A `TypeSig` (the result type) if the plugin handles this combination -- A `TypeError` with a diagnostic message if the combination is a type error (e.g., cross-currency money addition) -- `null` to defer to the core type rules +This keeps extension composition deterministic. -The checker tries each plugin **before** the core operator rules. This allows plugins to both extend (new type combinations) and restrict (type errors for invalid combinations) operator behaviour. +--- -**`checkCall`** — Given a function/intrinsic name and the inferred argument types, return the result type if the plugin overrides the built-in type checking for this call. Returns `null` to defer. This is used when a plugin-defined type changes the return type of a core intrinsic (e.g., `round(money, n)` → `money` instead of `number`). +## 16. Standardized Money Extension -**Example**: The money plugin's checker infers `£100` as `money(GBP)`, defines `money(GBP) + money(GBP)` → `money(GBP)`, rejects `money(GBP) + number` with a descriptive error, and overrides `round(money, n)` to return `money`. +This section is normative for implementations that ship the standard money +extension. It is not part of the core language. -#### Evaluator Hooks +### 16.1 Type -The evaluator hooks allow a plugin to handle operator evaluation and provide intrinsic function overrides. +`money(CURRENCY)` is a parameterized extension type. The currency is part of the +type. +```axiom +type Premium = money(GBP) ``` -EvaluatorHooks - supportsOp?(left: any, right: any, op: string) -> bool - evaluateOp?(left: any, right: any, op: string) -> any - intrinsics?: map(string -> function(...args) -> any | undefined) -``` - -**`supportsOp` / `evaluateOp`** — The evaluator checks each plugin before the core operator implementation. If `supportsOp` returns true, `evaluateOp` is called to produce the result. This allows plugins to define runtime behaviour for their types (e.g., money arithmetic that preserves currency metadata). - -**`intrinsics`** — A map of function names to implementations. When the evaluator encounters a call to a registered intrinsic, the plugin's implementation is tried first. If it returns `undefined`, the evaluator falls through to the built-in implementation. This allows plugins to override built-in intrinsics for specific argument types (e.g., `sum` over a list of money values). -**Example**: The money plugin's evaluator handles `money + money`, `money * number`, etc. at runtime, preserving the `{ amount, currency }` structure. It overrides `round`, `max`, `min`, and `sum` to work with money values. +### 16.2 Literals -#### Dispatch Order +Money literals use a currency prefix followed by a decimal amount: -Plugins are registered in a defined order. At each hook point: - -1. Plugins are tried in registration order -2. The first plugin that returns a non-null result wins -3. If no plugin handles the hook, the core implementation runs - -This means a plugin registered earlier takes priority. In practice, plugins operate on disjoint types (money, interval, etc.) so ordering rarely matters. - -#### Boot Sequence - -``` -for each plugin in registered_plugins: - register plugin.lexer hooks with lexer - register plugin.checker hooks with checker - register plugin.evaluator hooks with evaluator +```axiom +£100 +GBP100 +USD1500.00 ``` -Plugin registration happens once at program load time, before parsing. The set of active plugins is immutable for the lifetime of a program evaluation. +### 16.3 Arithmetic Rules -#### Scope of Extensions +| Expression | Result | +|------------|--------| +| `money(C) + money(C)` | `money(C)` | +| `money(C) - money(C)` | `money(C)` | +| `money(C) * number` | `money(C)` | +| `number * money(C)` | `money(C)` | +| `money(C) / non_zero` | `money(C)` | +| `money(C) / money(C)` | `number` | -With table declarations resolving external data (§14.1), the scope of plugins is focused: +Cross-currency arithmetic is a type error. -| Extension point | What it provides | Example | -|---|---|---| -| Custom literal syntax | New token forms in source text | `£100`, `(0..1000]` | -| Parameterized types | New types with parameters | `money(GBP)`, `interval(number)` | -| Operator overloading | Type rules + runtime behaviour for new types | `money + money`, `money * number` | -| Intrinsic overrides | Type-specific behaviour for core functions | `round(money)`, `sum(list(money))` | -| Named types | Shared domain vocabulary under a namespace | `insurance.CoverOutcome` | -| Pattern matchers | Custom match pattern forms | interval patterns | +### 16.4 Comparisons -Plugins do **not** provide: -- New syntax forms beyond literals (no new operators, no new keywords) -- Mutable state or side effects -- External data access (handled by tables, §14.1) +Comparison operators are valid only between values of the same `money(C)` type. -#### Validated with Prototype +### 16.5 Rounding and Aggregation -The `axiom-money` plugin was implemented in the playground, demonstrating all extension points working end-to-end: custom lexer tokens (`£500`, `EUR1000`), type inference and operator checking (`money(GBP) + money(GBP)` → `money(GBP)`, `money + number` → type error), intrinsic overrides (`round`, `max`, `min`, `sum`), and runtime evaluation. The healthcare and tradespeople examples use 116 and 88 money tokens respectively with zero type errors. +The standard money extension overloads: -### 14.7 Collection Predicate Narrowing — RESOLVED +- `round` +- `sum` +- `product` when explicitly defined by the implementation -**Resolution**: Yes, `any`/`all` predicates narrow variant types in conditional branches. This is a v1 feature with a full design specified in §12.5 of the revised spec. +### 16.6 Coercion -**Rules**: - -For `if any P in xs then A else B` where `P` is a variant pattern and `xs` has variant element type `T`: -- `A` is checked under the original type of `xs` -- `B` is checked with `xs` narrowed to `list(T minus matched alternatives)` - -For `if all P in xs then A else B`: -- `A` is checked with `xs` narrowed to `list(matched alternatives only)` -- `B` is checked under the original type of `xs` - -**Primary use case**: In the `else` branch of `if any referred in covers`, `covers` is narrowed to exclude `referred`. An aggregated collect over `rated`/`not_available` in that branch is exhaustive without a wildcard arm: +The standard money extension may define total coercions such as: ```axiom -if any referred in covers - then referred { reasons: collect referred { reason: r } in covers => r } - else offered { - total: sum collect in covers { - rated { premium: p } => p, - not_available => 0, - // no "referred" arm needed — type system knows it's excluded - }, - } +150 as money(GBP) ``` -This narrowing applies only to the direct recognised forms. Logically equivalent derived expressions do not trigger narrowing in v1. The playground does not yet implement this, but the spec design is complete. - -### 14.8 Pretty Printer and Round-Trip — DEFERRED - -**Status**: Desirable but not blocking v1. The revised spec defines the round-trip property `parse(prettyPrint(ast))` yields an equivalent AST (§15), but implementation and testing are deferred. - -### 14.9 Namespace `use` / Imports — DEFERRED - -**Status**: Not in scope for v1. v1 uses single-file programs with fully qualified namespace references. Multi-file programs and imports are a v2 concern. - -### 14.10 Recursion Detection — RESOLVED - -**Resolution**: Yes, the type checker detects and rejects circular call dependencies between named expressions. Mutual recursion is a type error — Axiom does not support recursion as a control structure. This is specified as a soundness check in §12.8 of the revised spec. +It does not define silent parsing of arbitrary invalid strings. --- -## 15. Full Example +## 17. Summary -The following demonstrates most language features in a realistic insurance rating scenario: +Axiom v1 is a small, typed, deterministic DSL for authored business logic. -```axiom -// --- Input record types --- +Its core consists of: -type Exposure = { - industry: string, - number_of_employees: number, - turnover: number, - is_sole_trader: bool, - years_experience: number, -} +- named expressions with declared interfaces +- nominal record and variant types +- ordered immutable tables backed by validated artifacts +- `if`, `match`, and `where` +- list-oriented collection forms +- exact decimal numbers with static division safety +- explicit, total-only coercions +- a narrow extension model for value and type families -type ClaimsHistory = { - number_of_claims: number, - total_claims_value: number, -} - -type RiskScores = { - flood_risk: number, - theft_risk: number, - terrorism_risk: number, -} - -// --- Outcome types --- - -type CoverOutcome = - rated { - key: string, - name: string, - base_premium: number, - limit: number, - excess: number, - } - | not_available { reason: string } +Axiom v1 does not include: -type ProductOutcome = - offered { - covers: dict(CoverOutcome), - subtotal: number, - minimum_premium: number, - total_gross_premium: number, - total_net_premium: number, - commission_rate: number, - currency: string, - } - | declined { reasons: list(string) } - | referred { reasons: dict(string) } - -// --- Industry configuration --- - -namespace Industry { - BaseRate(industry: string): number { - match industry { - "DRI-945" => 0.85, - "DRI-946" => 1.10, - "DRI-947" => 0.65, - _ => 1.00, - } - } - - BaseExcess(industry: string): number { - match industry { - "DRI-945" => 500, - "DRI-946" => 250, - _ => 100, - } - } -} - -// --- Claims loading --- - -namespace Claims { - TotalLoading(number_of_claims: number, total_claims_value: number): number { - FrequencyLoading(number_of_claims) * SeverityLoading(total_claims_value) - } - - FrequencyLoading(number_of_claims: number): number { - match number_of_claims { - 0 => 1, - 1 => 1.1, - 2 => 1.25, - [3..5] => 1.5, - _ => 2.0, - } - } - - SeverityLoading(total_claims_value: number): number { - match total_claims_value { - [0..10000] => 1, - (10000..50000] => 1.15, - (50000..100000] => 1.35, - _ => 1.6, - } - } -} - -// --- Cover rating --- - -namespace PublicLiability { - Rate(exposure: Exposure, limit: number, total_claims_loading: number): CoverOutcome { - rated { - key: "PL", - name: "Public Liability", - base_premium: round(base * total_claims_loading, 2), - limit, - excess: Industry.BaseExcess(industry: exposure.industry), - } - where base = exposure.turnover / 1000 - * Industry.BaseRate(industry: exposure.industry) - * limit / 1000000 - } -} - -// --- Product entry point --- - -Product(exposure: Exposure, claims: ClaimsHistory, pl_limit: number): ProductOutcome { - offered { - covers, - subtotal, - minimum_premium: 500, - total_gross_premium: round(max(subtotal, 500), 2), - total_net_premium: round(max(subtotal, 500) * (1 - 0.35), 2), - commission_rate: 0.35, - currency: "GBP", - } - where total_claims_loading = Claims.TotalLoading( - number_of_claims: claims.number_of_claims, - total_claims_value: claims.total_claims_value, - ), - covers = { - pl: PublicLiability.Rate(exposure, limit: pl_limit, total_claims_loading), - }, - subtotal = sum collect rated { base_premium: p } in covers => p -} -``` - ---- +- mutation +- loops +- indexing +- dynamic maps +- implicit IO +- syntax-extending plugins +- silent fallback semantics -## 16. Summary - -Axiom v1 is a typed expression language for authored business computation. Its core is: - -- **Named expressions** with typed parameters as the unit of authorship -- **Composition by calling** — expressions call other expressions -- **Where clauses** for naming intermediate values within an expression body -- **Spread** and **shorthand notation** for reducing argument-passing boilerplate -- **Record types** for structuring input data with named shapes -- **Variant types** — closed tagged unions with optional payloads, including payload-less tags -- **Nominal typing** for named types, structural compatibility for dicts -- **`if/then/else`** for boolean conditions, **`match`** for multi-arm dispatch and variant narrowing -- **Pattern matching** with literals, wildcards, ranges, alternatives, variants, and tuples -- **Collection forms** (`any`, `all`, `collect`, aggregate `collect`) for working with lists of variants -- **Explicit coercion** with `as` — no hidden type conversions -- **Namespaces** for organising related types and expressions -- **A small set of intrinsics** (`round`, `len`, `flatten`, `sum`, `product`, `max`, `min`) -- **No null**, no mutation, no loops, no side effects -- **A strong execution guarantee**: if it parses, type-checks, and validates, it cannot fail +The intent of v1 is to be small enough to specify precisely, implement consistently, +and review with confidence. diff --git a/composer.json b/composer.json index c523700..bd40159 100644 --- a/composer.json +++ b/composer.json @@ -1,26 +1,18 @@ { "name": "gosuperscript/axiom", - "description": "A PHP library for data transformation, type validation, and expression evaluation.", + "description": "PHP reference implementation workspace for the Axiom DSL.", "type": "library", "license": "MIT", - "keywords": ["php", "axiom", "validation", "coercion", "types"], + "keywords": ["php", "axiom", "dsl", "rules", "pricing"], "require": { "php": "^8.4", "ext-intl": "*", - "azjezz/psl": "^3.2 || ^4.0", - "brick/math": "^0.12.0 || ^0.13.0", - "gosuperscript/monads": "^1.0.0", - "illuminate/container": "^11.0 || ^12.0", - "illuminate/support": "^11.0 || ^12.0", - "psr/container": "^2.0", - "sebastian/exporter": "^6.0 || ^7.0" + "brick/math": "^0.12.0 || ^0.13.0" }, "require-dev": { - "infection/infection": "^0.29.14", "laravel/pint": "^1.22", "phpstan/phpstan": "2.1.45", - "phpunit/phpunit": "12.5.11", - "robiningelbrecht/phpunit-coverage-tools": "^1.9" + "phpunit/phpunit": "12.5.11" }, "autoload": { "psr-4": { @@ -33,20 +25,16 @@ } }, "scripts": { - "test:types": "vendor/bin/phpstan analyse", - "test:unit": "vendor/bin/phpunit -d --min-coverage=100", - "test:infection": "vendor/bin/infection --threads=max --show-mutations", + "test:types": "vendor/bin/phpstan analyse --configuration phpstan.neon.dist", + "test:unit": "vendor/bin/phpunit --configuration phpunit.xml.dist", + "format": "vendor/bin/pint", "test": [ "@test:types", - "@test:unit", - "@test:infection" + "@test:unit" ] }, "config": { "sort-packages": true, - "allow-plugins": { - "infection/extension-installer": true - }, "platform": { "php": "8.4.0" } diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 0000000..76b69ef --- /dev/null +++ b/legacy/README.md @@ -0,0 +1,24 @@ +# Legacy Archive + +This directory contains the archived PHP library that predates the rewritten +Axiom v1 specification. + +It is preserved for reference while the new PHP implementation starts cleanly +from the root [`src/`](../src) and [`tests/`](../tests) directories. + +## Contents + +- `src/`: archived PHP source +- `tests/`: archived PHP tests +- `composer.json`: archived package manifest for the legacy runtime +- `phpunit.xml.dist`, `phpstan.neon.dist`, `infection.json5`, `pint.json`: + archived quality/config files + +## Status + +- not the active implementation +- not the root package surface +- useful as reference material during the rewrite + +If you need to inspect or run the legacy code, do so from this directory +explicitly rather than treating it as the active root runtime. diff --git a/legacy/composer.json b/legacy/composer.json new file mode 100644 index 0000000..ea6cf2e --- /dev/null +++ b/legacy/composer.json @@ -0,0 +1,56 @@ +{ + "name": "gosuperscript/axiom-legacy", + "description": "Archived pre-v1 PHP expression library for Axiom.", + "type": "library", + "license": "MIT", + "keywords": ["php", "axiom", "legacy", "validation", "coercion"], + "require": { + "php": "^8.4", + "ext-intl": "*", + "azjezz/psl": "^3.2 || ^4.0", + "brick/math": "^0.12.0 || ^0.13.0", + "gosuperscript/monads": "^1.0.0", + "illuminate/container": "^11.0 || ^12.0", + "illuminate/support": "^11.0 || ^12.0", + "psr/container": "^2.0", + "sebastian/exporter": "^6.0 || ^7.0" + }, + "require-dev": { + "infection/infection": "^0.29.14", + "laravel/pint": "^1.22", + "phpstan/phpstan": "2.1.44", + "phpunit/phpunit": "12.5.11", + "robiningelbrecht/phpunit-coverage-tools": "^1.9" + }, + "autoload": { + "psr-4": { + "Superscript\\Axiom\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Superscript\\Axiom\\Tests\\": "tests/" + } + }, + "scripts": { + "test:types": "vendor/bin/phpstan analyse --configuration phpstan.neon.dist", + "test:unit": "vendor/bin/phpunit --configuration phpunit.xml.dist --min-coverage=100", + "test:infection": "vendor/bin/infection --threads=max --show-mutations", + "test": [ + "@test:types", + "@test:unit", + "@test:infection" + ] + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true + }, + "platform": { + "php": "8.4.0" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/infection.json5 b/legacy/infection.json5 similarity index 100% rename from infection.json5 rename to legacy/infection.json5 diff --git a/legacy/phpstan.neon.dist b/legacy/phpstan.neon.dist new file mode 100644 index 0000000..4cad4cc --- /dev/null +++ b/legacy/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + paths: + - src/ + + level: max + + treatPhpDocTypesAsCertain: false \ No newline at end of file diff --git a/legacy/phpunit.xml.dist b/legacy/phpunit.xml.dist new file mode 100644 index 0000000..d311187 --- /dev/null +++ b/legacy/phpunit.xml.dist @@ -0,0 +1,43 @@ + + + + + tests + + + + + + + + + + + + + + + + + + ./src + + + \ No newline at end of file diff --git a/legacy/pint.json b/legacy/pint.json new file mode 100644 index 0000000..6a5955e --- /dev/null +++ b/legacy/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "per", + "rules": { + "declare_strict_types": true + } +} \ No newline at end of file diff --git a/src/Exceptions/TransformValueException.php b/legacy/src/Exceptions/TransformValueException.php similarity index 100% rename from src/Exceptions/TransformValueException.php rename to legacy/src/Exceptions/TransformValueException.php diff --git a/src/Operators/BinaryOverloader.php b/legacy/src/Operators/BinaryOverloader.php similarity index 100% rename from src/Operators/BinaryOverloader.php rename to legacy/src/Operators/BinaryOverloader.php diff --git a/src/Operators/ComparisonOverloader.php b/legacy/src/Operators/ComparisonOverloader.php similarity index 100% rename from src/Operators/ComparisonOverloader.php rename to legacy/src/Operators/ComparisonOverloader.php diff --git a/src/Operators/DefaultOverloader.php b/legacy/src/Operators/DefaultOverloader.php similarity index 100% rename from src/Operators/DefaultOverloader.php rename to legacy/src/Operators/DefaultOverloader.php diff --git a/src/Operators/HasOverloader.php b/legacy/src/Operators/HasOverloader.php similarity index 100% rename from src/Operators/HasOverloader.php rename to legacy/src/Operators/HasOverloader.php diff --git a/src/Operators/InOverloader.php b/legacy/src/Operators/InOverloader.php similarity index 100% rename from src/Operators/InOverloader.php rename to legacy/src/Operators/InOverloader.php diff --git a/src/Operators/IntersectsOverloader.php b/legacy/src/Operators/IntersectsOverloader.php similarity index 100% rename from src/Operators/IntersectsOverloader.php rename to legacy/src/Operators/IntersectsOverloader.php diff --git a/src/Operators/LogicalOverloader.php b/legacy/src/Operators/LogicalOverloader.php similarity index 100% rename from src/Operators/LogicalOverloader.php rename to legacy/src/Operators/LogicalOverloader.php diff --git a/src/Operators/NullOverloader.php b/legacy/src/Operators/NullOverloader.php similarity index 100% rename from src/Operators/NullOverloader.php rename to legacy/src/Operators/NullOverloader.php diff --git a/src/Operators/OperatorOverloader.php b/legacy/src/Operators/OperatorOverloader.php similarity index 100% rename from src/Operators/OperatorOverloader.php rename to legacy/src/Operators/OperatorOverloader.php diff --git a/src/Operators/OverloaderManager.php b/legacy/src/Operators/OverloaderManager.php similarity index 100% rename from src/Operators/OverloaderManager.php rename to legacy/src/Operators/OverloaderManager.php diff --git a/src/Patterns/ExpressionMatcher.php b/legacy/src/Patterns/ExpressionMatcher.php similarity index 100% rename from src/Patterns/ExpressionMatcher.php rename to legacy/src/Patterns/ExpressionMatcher.php diff --git a/src/Patterns/LiteralMatcher.php b/legacy/src/Patterns/LiteralMatcher.php similarity index 100% rename from src/Patterns/LiteralMatcher.php rename to legacy/src/Patterns/LiteralMatcher.php diff --git a/src/Patterns/PatternMatcher.php b/legacy/src/Patterns/PatternMatcher.php similarity index 100% rename from src/Patterns/PatternMatcher.php rename to legacy/src/Patterns/PatternMatcher.php diff --git a/src/Patterns/WildcardMatcher.php b/legacy/src/Patterns/WildcardMatcher.php similarity index 100% rename from src/Patterns/WildcardMatcher.php rename to legacy/src/Patterns/WildcardMatcher.php diff --git a/src/ResolutionInspector.php b/legacy/src/ResolutionInspector.php similarity index 100% rename from src/ResolutionInspector.php rename to legacy/src/ResolutionInspector.php diff --git a/src/Resolvers/BindableResolver.php b/legacy/src/Resolvers/BindableResolver.php similarity index 100% rename from src/Resolvers/BindableResolver.php rename to legacy/src/Resolvers/BindableResolver.php diff --git a/src/Resolvers/DelegatingResolver.php b/legacy/src/Resolvers/DelegatingResolver.php similarity index 100% rename from src/Resolvers/DelegatingResolver.php rename to legacy/src/Resolvers/DelegatingResolver.php diff --git a/src/Resolvers/InfixResolver.php b/legacy/src/Resolvers/InfixResolver.php similarity index 100% rename from src/Resolvers/InfixResolver.php rename to legacy/src/Resolvers/InfixResolver.php diff --git a/src/Resolvers/MatchResolver.php b/legacy/src/Resolvers/MatchResolver.php similarity index 100% rename from src/Resolvers/MatchResolver.php rename to legacy/src/Resolvers/MatchResolver.php diff --git a/src/Resolvers/MemberAccessResolver.php b/legacy/src/Resolvers/MemberAccessResolver.php similarity index 100% rename from src/Resolvers/MemberAccessResolver.php rename to legacy/src/Resolvers/MemberAccessResolver.php diff --git a/src/Resolvers/Resolver.php b/legacy/src/Resolvers/Resolver.php similarity index 100% rename from src/Resolvers/Resolver.php rename to legacy/src/Resolvers/Resolver.php diff --git a/src/Resolvers/StaticResolver.php b/legacy/src/Resolvers/StaticResolver.php similarity index 100% rename from src/Resolvers/StaticResolver.php rename to legacy/src/Resolvers/StaticResolver.php diff --git a/src/Resolvers/SymbolResolver.php b/legacy/src/Resolvers/SymbolResolver.php similarity index 100% rename from src/Resolvers/SymbolResolver.php rename to legacy/src/Resolvers/SymbolResolver.php diff --git a/src/Resolvers/UnaryResolver.php b/legacy/src/Resolvers/UnaryResolver.php similarity index 100% rename from src/Resolvers/UnaryResolver.php rename to legacy/src/Resolvers/UnaryResolver.php diff --git a/src/Resolvers/ValueResolver.php b/legacy/src/Resolvers/ValueResolver.php similarity index 100% rename from src/Resolvers/ValueResolver.php rename to legacy/src/Resolvers/ValueResolver.php diff --git a/src/Source.php b/legacy/src/Source.php similarity index 100% rename from src/Source.php rename to legacy/src/Source.php diff --git a/src/Sources/ExpressionPattern.php b/legacy/src/Sources/ExpressionPattern.php similarity index 100% rename from src/Sources/ExpressionPattern.php rename to legacy/src/Sources/ExpressionPattern.php diff --git a/src/Sources/InfixExpression.php b/legacy/src/Sources/InfixExpression.php similarity index 100% rename from src/Sources/InfixExpression.php rename to legacy/src/Sources/InfixExpression.php diff --git a/src/Sources/LiteralPattern.php b/legacy/src/Sources/LiteralPattern.php similarity index 100% rename from src/Sources/LiteralPattern.php rename to legacy/src/Sources/LiteralPattern.php diff --git a/src/Sources/MatchArm.php b/legacy/src/Sources/MatchArm.php similarity index 100% rename from src/Sources/MatchArm.php rename to legacy/src/Sources/MatchArm.php diff --git a/src/Sources/MatchExpression.php b/legacy/src/Sources/MatchExpression.php similarity index 100% rename from src/Sources/MatchExpression.php rename to legacy/src/Sources/MatchExpression.php diff --git a/src/Sources/MatchPattern.php b/legacy/src/Sources/MatchPattern.php similarity index 100% rename from src/Sources/MatchPattern.php rename to legacy/src/Sources/MatchPattern.php diff --git a/src/Sources/MemberAccessSource.php b/legacy/src/Sources/MemberAccessSource.php similarity index 100% rename from src/Sources/MemberAccessSource.php rename to legacy/src/Sources/MemberAccessSource.php diff --git a/src/Sources/StaticSource.php b/legacy/src/Sources/StaticSource.php similarity index 100% rename from src/Sources/StaticSource.php rename to legacy/src/Sources/StaticSource.php diff --git a/src/Sources/SymbolSource.php b/legacy/src/Sources/SymbolSource.php similarity index 100% rename from src/Sources/SymbolSource.php rename to legacy/src/Sources/SymbolSource.php diff --git a/src/Sources/TypeDefinition.php b/legacy/src/Sources/TypeDefinition.php similarity index 100% rename from src/Sources/TypeDefinition.php rename to legacy/src/Sources/TypeDefinition.php diff --git a/src/Sources/UnaryExpression.php b/legacy/src/Sources/UnaryExpression.php similarity index 100% rename from src/Sources/UnaryExpression.php rename to legacy/src/Sources/UnaryExpression.php diff --git a/src/Sources/WildcardPattern.php b/legacy/src/Sources/WildcardPattern.php similarity index 100% rename from src/Sources/WildcardPattern.php rename to legacy/src/Sources/WildcardPattern.php diff --git a/legacy/src/SymbolRegistry.php b/legacy/src/SymbolRegistry.php new file mode 100644 index 0000000..dd5d033 --- /dev/null +++ b/legacy/src/SymbolRegistry.php @@ -0,0 +1,65 @@ + */ + private array $symbols; + + /** + * @param array> $symbols + */ + public function __construct(array $symbols = []) + { + // Transform the array into internal storage format + $internalSymbols = []; + + foreach ($symbols as $key => $value) { + // If value is a Source, add it without namespace + if ($value instanceof Source) { + $internalSymbols[$key] = $value; + } + // If value is an array, the key is the namespace + elseif (is_array($value)) { + // Validate that the array contains only Sources + dict(string(), instance_of(Source::class))->assert($value); + + foreach ($value as $name => $source) { + $namespacedKey = $key . '.' . $name; + $internalSymbols[$namespacedKey] = $source; + } + } else { + throw new \InvalidArgumentException( + 'Symbol values must be either Source instances or arrays of Sources' + ); + } + } + + $this->symbols = $internalSymbols; + } + + /** + * @return Option + */ + public function get(string $name, ?string $namespace = null): Option + { + // When namespace is provided, look for it with format "namespace.name" + if ($namespace !== null) { + $namespacedKey = $namespace . '.' . $name; + return Option::from($this->symbols[$namespacedKey] ?? null); + } + + // When namespace is null, first try exact name match, + // then fall back to checking if there's a global namespace symbol + return Option::from($this->symbols[$name] ?? null); + } +} diff --git a/src/Types/BooleanType.php b/legacy/src/Types/BooleanType.php similarity index 100% rename from src/Types/BooleanType.php rename to legacy/src/Types/BooleanType.php diff --git a/src/Types/DictType.php b/legacy/src/Types/DictType.php similarity index 100% rename from src/Types/DictType.php rename to legacy/src/Types/DictType.php diff --git a/src/Types/ListType.php b/legacy/src/Types/ListType.php similarity index 100% rename from src/Types/ListType.php rename to legacy/src/Types/ListType.php diff --git a/legacy/src/Types/NumberType.php b/legacy/src/Types/NumberType.php new file mode 100644 index 0000000..ba44496 --- /dev/null +++ b/legacy/src/Types/NumberType.php @@ -0,0 +1,62 @@ + + */ +class NumberType implements Type +{ + public function assert(mixed $value): Result + { + if (!num()->matches($value)) { + return new Err(new TransformValueException(type: 'numeric', value: $value)); + } + + return Ok(Some($value)); + } + + public function coerce(mixed $value): Result + { + if (is_string($value) && ($value === '' || $value === 'null')) { + return Ok(None()); + } + + return (match (true) { + numeric_string()->matches($value) || num()->matches($value) => Ok(num()->coerce($value)), + is_string($value) && numeric_string()->matches(before($value, '%')) => Ok(num()->coerce(before($value, '%')) / 100), + default => new Err(new TransformValueException(type: 'numeric', value: $value)), + })->map(fn(int|float $value) => Some($value)); + } + + /** + * @inheritDoc + */ + public function compare(mixed $a, mixed $b): bool + { + return $a === $b; + } + + public function format(mixed $value): string + { + $formatter = new NumberFormatter('en_GB', NumberFormatter::DECIMAL); + + return string()->assert($formatter->format($value)); + } +} diff --git a/src/Types/StringType.php b/legacy/src/Types/StringType.php similarity index 100% rename from src/Types/StringType.php rename to legacy/src/Types/StringType.php diff --git a/legacy/src/Types/Type.php b/legacy/src/Types/Type.php new file mode 100644 index 0000000..dba6b11 --- /dev/null +++ b/legacy/src/Types/Type.php @@ -0,0 +1,42 @@ +, Throwable> + */ + public function assert(mixed $value): Result; + + /** + * Try to coerce a mixed value into type T + * @param mixed $value + * @return Result, Throwable> + */ + public function coerce(mixed $value): Result; + + /** + * @param T $a + * @param T $b + * @return bool + */ + public function compare(mixed $a, mixed $b): bool; + + /** + * @param T $value + * @return string + */ + public function format(mixed $value): string; +} diff --git a/tests/DefaultOverloaderTest.php b/legacy/tests/DefaultOverloaderTest.php similarity index 100% rename from tests/DefaultOverloaderTest.php rename to legacy/tests/DefaultOverloaderTest.php diff --git a/tests/Exceptions/TransformValueExceptionTest.php b/legacy/tests/Exceptions/TransformValueExceptionTest.php similarity index 100% rename from tests/Exceptions/TransformValueExceptionTest.php rename to legacy/tests/Exceptions/TransformValueExceptionTest.php diff --git a/tests/KitchenSink/KitchenSinkTest.php b/legacy/tests/KitchenSink/KitchenSinkTest.php similarity index 100% rename from tests/KitchenSink/KitchenSinkTest.php rename to legacy/tests/KitchenSink/KitchenSinkTest.php diff --git a/tests/LogicalOverloaderTest.php b/legacy/tests/LogicalOverloaderTest.php similarity index 100% rename from tests/LogicalOverloaderTest.php rename to legacy/tests/LogicalOverloaderTest.php diff --git a/tests/Operators/InOverloaderTest.php b/legacy/tests/Operators/InOverloaderTest.php similarity index 100% rename from tests/Operators/InOverloaderTest.php rename to legacy/tests/Operators/InOverloaderTest.php diff --git a/tests/OverloaderManagerTest.php b/legacy/tests/OverloaderManagerTest.php similarity index 100% rename from tests/OverloaderManagerTest.php rename to legacy/tests/OverloaderManagerTest.php diff --git a/tests/Patterns/ExpressionMatcherTest.php b/legacy/tests/Patterns/ExpressionMatcherTest.php similarity index 100% rename from tests/Patterns/ExpressionMatcherTest.php rename to legacy/tests/Patterns/ExpressionMatcherTest.php diff --git a/tests/Patterns/LiteralMatcherTest.php b/legacy/tests/Patterns/LiteralMatcherTest.php similarity index 100% rename from tests/Patterns/LiteralMatcherTest.php rename to legacy/tests/Patterns/LiteralMatcherTest.php diff --git a/tests/Patterns/WildcardMatcherTest.php b/legacy/tests/Patterns/WildcardMatcherTest.php similarity index 100% rename from tests/Patterns/WildcardMatcherTest.php rename to legacy/tests/Patterns/WildcardMatcherTest.php diff --git a/tests/Resolvers/DelegatingResolverTest.php b/legacy/tests/Resolvers/DelegatingResolverTest.php similarity index 100% rename from tests/Resolvers/DelegatingResolverTest.php rename to legacy/tests/Resolvers/DelegatingResolverTest.php diff --git a/tests/Resolvers/Fixtures/Dependency.php b/legacy/tests/Resolvers/Fixtures/Dependency.php similarity index 100% rename from tests/Resolvers/Fixtures/Dependency.php rename to legacy/tests/Resolvers/Fixtures/Dependency.php diff --git a/tests/Resolvers/Fixtures/ResolverWithDependency.php b/legacy/tests/Resolvers/Fixtures/ResolverWithDependency.php similarity index 100% rename from tests/Resolvers/Fixtures/ResolverWithDependency.php rename to legacy/tests/Resolvers/Fixtures/ResolverWithDependency.php diff --git a/tests/Resolvers/Fixtures/SpyInspector.php b/legacy/tests/Resolvers/Fixtures/SpyInspector.php similarity index 100% rename from tests/Resolvers/Fixtures/SpyInspector.php rename to legacy/tests/Resolvers/Fixtures/SpyInspector.php diff --git a/tests/Resolvers/InfixResolverTest.php b/legacy/tests/Resolvers/InfixResolverTest.php similarity index 100% rename from tests/Resolvers/InfixResolverTest.php rename to legacy/tests/Resolvers/InfixResolverTest.php diff --git a/tests/Resolvers/MatchResolverTest.php b/legacy/tests/Resolvers/MatchResolverTest.php similarity index 100% rename from tests/Resolvers/MatchResolverTest.php rename to legacy/tests/Resolvers/MatchResolverTest.php diff --git a/tests/Resolvers/MemberAccessResolverTest.php b/legacy/tests/Resolvers/MemberAccessResolverTest.php similarity index 100% rename from tests/Resolvers/MemberAccessResolverTest.php rename to legacy/tests/Resolvers/MemberAccessResolverTest.php diff --git a/tests/Resolvers/ResolutionInspectorTest.php b/legacy/tests/Resolvers/ResolutionInspectorTest.php similarity index 100% rename from tests/Resolvers/ResolutionInspectorTest.php rename to legacy/tests/Resolvers/ResolutionInspectorTest.php diff --git a/tests/Resolvers/StaticResolverTest.php b/legacy/tests/Resolvers/StaticResolverTest.php similarity index 100% rename from tests/Resolvers/StaticResolverTest.php rename to legacy/tests/Resolvers/StaticResolverTest.php diff --git a/tests/Resolvers/SymbolResolverTest.php b/legacy/tests/Resolvers/SymbolResolverTest.php similarity index 100% rename from tests/Resolvers/SymbolResolverTest.php rename to legacy/tests/Resolvers/SymbolResolverTest.php diff --git a/tests/Resolvers/UnaryResolverTest.php b/legacy/tests/Resolvers/UnaryResolverTest.php similarity index 100% rename from tests/Resolvers/UnaryResolverTest.php rename to legacy/tests/Resolvers/UnaryResolverTest.php diff --git a/tests/Resolvers/ValueResolverTest.php b/legacy/tests/Resolvers/ValueResolverTest.php similarity index 100% rename from tests/Resolvers/ValueResolverTest.php rename to legacy/tests/Resolvers/ValueResolverTest.php diff --git a/legacy/tests/SymbolRegistryTest.php b/legacy/tests/SymbolRegistryTest.php new file mode 100644 index 0000000..e90e190 --- /dev/null +++ b/legacy/tests/SymbolRegistryTest.php @@ -0,0 +1,146 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Symbol values must be either Source instances or arrays of Sources'); + + (new SymbolRegistry([ + 'test' => 42, + ])); + } + + #[Test] + public function it_can_get_a_symbol_without_namespace(): void + { + $registry = new SymbolRegistry([ + 'A' => new StaticSource(1), + 'B' => new StaticSource(2), + ]); + + $result = $registry->get('A'); + $this->assertTrue($result->isSome()); + $this->assertInstanceOf(StaticSource::class, $result->unwrap()); + $this->assertEquals(1, $result->unwrap()->value); + } + + #[Test] + public function it_returns_none_for_nonexistent_symbol(): void + { + $registry = new SymbolRegistry([ + 'A' => new StaticSource(1), + ]); + + $result = $registry->get('B'); + $this->assertTrue($result->isNone()); + } + + #[Test] + public function it_can_get_a_namespaced_symbol(): void + { + $registry = new SymbolRegistry([ + 'math' => [ + 'pi' => new StaticSource(3.14), + 'e' => new StaticSource(2.71), + ], + 'constants' => [ + 'c' => new StaticSource(299792458), + ], + ]); + + $result = $registry->get('pi', 'math'); + $this->assertTrue($result->isSome()); + $this->assertEquals(3.14, $result->unwrap()->value); + + $result = $registry->get('e', 'math'); + $this->assertTrue($result->isSome()); + $this->assertEquals(2.71, $result->unwrap()->value); + + $result = $registry->get('c', 'constants'); + $this->assertTrue($result->isSome()); + $this->assertEquals(299792458, $result->unwrap()->value); + } + + #[Test] + public function it_returns_none_for_nonexistent_namespaced_symbol(): void + { + $registry = new SymbolRegistry([ + 'math' => [ + 'pi' => new StaticSource(3.14), + ], + ]); + + // Wrong namespace + $result = $registry->get('pi', 'physics'); + $this->assertTrue($result->isNone()); + + // Wrong name in correct namespace + $result = $registry->get('e', 'math'); + $this->assertTrue($result->isNone()); + } + + #[Test] + public function it_distinguishes_between_namespaced_and_non_namespaced_symbols(): void + { + $registry = new SymbolRegistry([ + 'value' => new StaticSource(1), + 'ns' => [ + 'value' => new StaticSource(2), + ], + ]); + + // Getting without namespace should return the non-namespaced symbol + $result = $registry->get('value'); + $this->assertTrue($result->isSome()); + $this->assertEquals(1, $result->unwrap()->value); + + // Getting with namespace should return the namespaced symbol + $result = $registry->get('value', 'ns'); + $this->assertTrue($result->isSome()); + $this->assertEquals(2, $result->unwrap()->value); + } + + #[Test] + public function it_supports_nested_namespace_keys(): void + { + $registry = new SymbolRegistry([ + 'level1' => [ + 'level2.value' => new StaticSource(42), + ], + ]); + + $result = $registry->get('level2.value', 'level1'); + $this->assertTrue($result->isSome()); + $this->assertEquals(42, $result->unwrap()->value); + } + + #[Test] + public function it_must_validate_namespaced_array_contains_only_sources(): void + { + $this->expectException(AssertException::class); + + (new SymbolRegistry([ + 'math' => [ + 'pi' => new StaticSource(3.14), + 'invalid' => 42, // Invalid: not a Source instance + ], + ])); + } +} diff --git a/tests/Types/BooleanTypeTest.php b/legacy/tests/Types/BooleanTypeTest.php similarity index 100% rename from tests/Types/BooleanTypeTest.php rename to legacy/tests/Types/BooleanTypeTest.php diff --git a/tests/Types/DictTypeTest.php b/legacy/tests/Types/DictTypeTest.php similarity index 100% rename from tests/Types/DictTypeTest.php rename to legacy/tests/Types/DictTypeTest.php diff --git a/tests/Types/ListTypeTest.php b/legacy/tests/Types/ListTypeTest.php similarity index 100% rename from tests/Types/ListTypeTest.php rename to legacy/tests/Types/ListTypeTest.php diff --git a/tests/Types/NumberTypeTest.php b/legacy/tests/Types/NumberTypeTest.php similarity index 100% rename from tests/Types/NumberTypeTest.php rename to legacy/tests/Types/NumberTypeTest.php diff --git a/tests/Types/StringTypeTest.php b/legacy/tests/Types/StringTypeTest.php similarity index 100% rename from tests/Types/StringTypeTest.php rename to legacy/tests/Types/StringTypeTest.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 4cad4cc..97f8527 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,7 +1,8 @@ parameters: paths: - src/ + - tests/ level: max - treatPhpDocTypesAsCertain: false \ No newline at end of file + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d311187..9eb1d80 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,42 +2,23 @@ tests - - - - - - - - - - - - - ./src - \ No newline at end of file + diff --git a/pint.json b/pint.json index 6a5955e..3f49bd3 100644 --- a/pint.json +++ b/pint.json @@ -1,6 +1,6 @@ { "preset": "per", "rules": { - "declare_strict_types": true + "declare_strict_types": true } -} \ No newline at end of file +} diff --git a/playground/src/examples/hospitality.axiom.ts b/playground/src/examples/hospitality.axiom.ts index 23cbe91..ada1343 100644 --- a/playground/src/examples/hospitality.axiom.ts +++ b/playground/src/examples/hospitality.axiom.ts @@ -59,7 +59,7 @@ type CoverOutcome = type ProductOutcome = offered { - covers: dict(CoverOutcome), + covers: list(CoverOutcome), subtotal: number, minimum_premium: number, total_gross_premium: number, @@ -68,7 +68,14 @@ type ProductOutcome = currency: string, } | declined { reasons: list(string) } - | referred { reasons: dict(string) } + | referred { reasons: list(string) } + +type MultiIndustrySummary = { + pl_classes: list(string), + el_classes: list(string), + deep_frying_rates: list(number), + minimum_premiums: list(number), +} // --- Industry configuration (from industry_config.csv, 28 columns x 8 industries) --- // Table declaration — typed companion data, loaded from CSV artifact at deploy time @@ -130,50 +137,17 @@ namespace Industry { match row in industry_config { row.code == industry => row.min_premium_default, _ => 0 } } - // --- Multi-industry lookups (worst-case across selected industries) --- - - // Classification fields: find the row with the highest severity - WorstPLClass(industries: list(string)): string { - match row in industry_config { - row.code in industries && row.pl_severity == worst => row.pl_class, - _ => "", - } - where worst = max collect row in industry_config { - row.code in industries => row.pl_severity, - } - } - - WorstELClass(industries: list(string)): string { - match row in industry_config { - row.code in industries && row.el_severity == worst => row.el_class, - _ => "", - } - where worst = max collect row in industry_config { - row.code in industries => row.el_severity, - } - } - - // Numeric fields: direct aggregation - MaxDeepFryingRate(industries: list(string)): number { - max collect row in industry_config { - row.code in industries => row.deep_frying_rate, - } - } - - MaxMinPremiumDefault(industries: list(string)): number { - max collect row in industry_config { - row.code in industries => row.min_premium_default, - } - } + // --- Multi-industry lookups --- + // Core v1 examples use collection queries directly rather than max/min aggregates. } // --- Claims loading system (product-level, shared across all covers) --- namespace Claims { // Years trading coefficient and loading (from years_trading_coefficient_and_loads.csv) - // Capped at min(5, yearsExperience) + // Years of experience are capped at 5. YearsTradingLoading(is_sole_trader: bool, years_experience: number): number { - match (is_sole_trader, min(5, years_experience)) { + match (is_sole_trader, capped_years) { (false, [0..1]) => 1, (false, 2) => 0.5, (false, 3) => 0.25, @@ -185,11 +159,12 @@ namespace Claims { (true, 5) => -0.25, _ => 0, } + where capped_years = if years_experience > 5 then 5 else years_experience } // Coefficient letter determines which column to use in claims cross-lookup Coefficient(is_sole_trader: bool, years_experience: number): string { - match (is_sole_trader, min(5, years_experience)) { + match (is_sole_trader, capped_years) { (false, [0..1]) | (true, [0..1]) => "A", (false, 2) | (true, 2) => "B", (false, 3) | (true, 3) => "C", @@ -197,6 +172,7 @@ namespace Claims { (false, 5) | (true, 5) => "E", _ => "A", } + where capped_years = if years_experience > 5 then 5 else years_experience } // Claims x years-trading cross-lookup (from claims_years_trading_loadings.csv) @@ -733,7 +709,7 @@ Product( else if bc.number_of_beds == "over20" then declined { reasons: ["Maximum 20 beds allowed"] } // Rate all covers, check for failures, assemble product - else if any not_available {} in covers + else if any not_available in covers then referred { reasons: collect not_available { reason } in covers => reason, } @@ -747,36 +723,48 @@ Product( currency: "GBP", } where total_claims_loading = Claims.TotalLoading(exposure, claims), - covers = { - pl: PublicLiability.Rate(exposure, limit: pl_limit, total_claims_loading), - bc: BuildingsContentsStock.Rate(exposure, bc, risks, total_claims_loading), - bi: BusinessInterruption.Rate(exposure, bi, total_claims_loading), - el: EmployersLiability.Rate(exposure, total_claims_loading), - pbe: PortableEquipment.Rate(limit: pbe_limit, total_claims_loading), - ter: Terrorism.Rate(risks, bc, bi), - }, + pl_cover = PublicLiability.Rate(exposure, limit: pl_limit, total_claims_loading), + bc_cover = BuildingsContentsStock.Rate(exposure, bc, risks, total_claims_loading), + bi_cover = BusinessInterruption.Rate(exposure, bi, total_claims_loading), + el_cover = EmployersLiability.Rate(exposure, total_claims_loading), + pbe_cover = PortableEquipment.Rate(limit: pbe_limit, total_claims_loading), + ter_cover = Terrorism.Rate(risks, bc, bi), + covers = [ + pl_cover, + bc_cover, + bi_cover, + el_cover, + pbe_cover, + ter_cover, + ], base_sum = sum(collect rated { base_premium } in covers => base_premium), - ter_premium = match covers.ter { + ter_premium = match ter_cover { rated { base_premium } => base_premium, _ => 0, }, subtotal = base_sum - ter_premium, min_prem = MinimumPremium(exposure, claims, bc), - total = max(min_prem, subtotal) + ter_premium + floored_subtotal = if min_prem > subtotal then min_prem else subtotal, + total = floored_subtotal + ter_premium } // --- Multi-industry demonstration --- -// When a product covers multiple industries, find the worst-case configuration +// Shows how a product can query multiple rows across selected industries. -MultiIndustryDemo(industries: list(string)) { +MultiIndustryDemo(industries: list(string)): MultiIndustrySummary { { - worst_pl_class: Industry.WorstPLClass(industries), - worst_el_class: Industry.WorstELClass(industries), - max_deep_frying_rate: Industry.MaxDeepFryingRate(industries), - max_min_premium: Industry.MaxMinPremiumDefault(industries), - all_pl_classes: collect row in industry_config { + pl_classes: collect row in industry_config { row.code in industries => row.pl_class, }, + el_classes: collect row in industry_config { + row.code in industries => row.el_class, + }, + deep_frying_rates: collect row in industry_config { + row.code in industries => row.deep_frying_rate, + }, + minimum_premiums: collect row in industry_config { + row.code in industries => row.min_premium_default, + }, } } `; diff --git a/playground/src/examples/insurance.axiom.ts b/playground/src/examples/insurance.axiom.ts index 7fd22f7..a35bfc7 100644 --- a/playground/src/examples/insurance.axiom.ts +++ b/playground/src/examples/insurance.axiom.ts @@ -10,12 +10,12 @@ type RuleResult = | referred { message: string } type CoverOutcome = - not_selected {} + not_selected | rated { premium: number, loading: number, notes: list(string), endorsements: list(Endorsement) } | referred { reasons: list(string) } type ProductOutcome = - offered { total: number, covers: dict(CoverOutcome) } + offered { total: number, covers: list(CoverOutcome) } | referred { reasons: list(string) } BuildingsConstructionRule(quote: { construction: string }): RuleResult { @@ -59,9 +59,11 @@ AggregateRules( else rated { premium: base_premium * product collect in rules { ok { factor } => factor, + _ => 1.00, }, loading: product collect in rules { ok { factor } => factor, + _ => 1.00, }, notes: flatten(collect ok { notes: n } in rules => n), endorsements: flatten(collect ok { endorsements: e } in rules => e), @@ -78,7 +80,7 @@ BuildingsCover( base_rate: number, ): CoverOutcome { if not quote.has_buildings - then not_selected {} + then not_selected else AggregateRules( rules: [ BuildingsConstructionRule(quote: quote), @@ -96,7 +98,7 @@ ContentsCover( base_rate: number, ): CoverOutcome { if not quote.has_contents - then not_selected {} + then not_selected else rated { premium: quote.contents_sum_insured / 1000 * base_rate, loading: 1.00, @@ -105,7 +107,7 @@ ContentsCover( } } -AggregateCovers(covers: dict(CoverOutcome)): ProductOutcome { +AggregateCovers(covers: list(CoverOutcome)): ProductOutcome { if any referred in covers then referred { reasons: flatten(collect referred { reasons: rs } in covers => rs), @@ -130,10 +132,10 @@ Product( contents_rate: number, }, ): ProductOutcome { - AggregateCovers(covers: { - buildings: BuildingsCover(quote: quote, base_rate: rates.buildings_rate), - contents: ContentsCover(quote: quote, base_rate: rates.contents_rate), - }) + AggregateCovers(covers: [ + BuildingsCover(quote: quote, base_rate: rates.buildings_rate), + ContentsCover(quote: quote, base_rate: rates.contents_rate), + ]) } `; diff --git a/playground/src/examples/landlords.axiom.ts b/playground/src/examples/landlords.axiom.ts index 466cd5d..6edf5da 100644 --- a/playground/src/examples/landlords.axiom.ts +++ b/playground/src/examples/landlords.axiom.ts @@ -34,9 +34,18 @@ type ClaimsHistory = { // --- Outcome types --- +type PropertyBreakdown = { + address: string, + buildings_premium: number, + contents_premium: number, + rent_premium: number, + pol_premium: number, + property_total: number, +} + type ProductOutcome = offered { - property_details: list(dict), + property_details: list(PropertyBreakdown), property_subtotal: number, discount_rate: number, property_net: number, @@ -157,7 +166,7 @@ namespace PropertyRating { } // Per-property breakdown with rounded values - Breakdown(prop: Property) { + Breakdown(prop: Property): PropertyBreakdown { { address: prop.address, buildings_premium: round(BuildingsPremium(prop), 2), @@ -242,14 +251,6 @@ ClaimsLoading(claims: ClaimsHistory): number { // --- Worst-case lookups across all properties --- // Useful for underwriting rules that check the riskiest property -WorstFloodRisk(properties: list(Property)): number { - max collect prop in properties => prop.flood_risk -} - -WorstSubsidenceRisk(properties: list(Property)): number { - max collect prop in properties => prop.subsidence_risk -} - TotalBuildingsSI(properties: list(Property)): number { sum collect prop in properties => prop.buildings_sum_insured } diff --git a/playground/src/examples/money.axiom.ts b/playground/src/examples/money.axiom.ts index 69907bf..5a0eeed 100644 --- a/playground/src/examples/money.axiom.ts +++ b/playground/src/examples/money.axiom.ts @@ -3,6 +3,15 @@ export const MONEY_EXAMPLE = `// Money Type Plugin Demo // --- Premium calculation with money types --- +type MoneyBreakdown = { + base_premium: money(GBP), + discount: money(GBP), + admin_fee: money(GBP), + ipt: money(GBP), + total: money(GBP), + affordable: bool, +} + BasePremium(risk_score: number): money(GBP) { match risk_score { [1..3] => £500, @@ -12,7 +21,7 @@ BasePremium(risk_score: number): money(GBP) { } } -AdminFee: money(GBP) { +AdminFee(): money(GBP) { £35 } @@ -34,7 +43,7 @@ PropertyDiscount(num_properties: number, subtotal: money(GBP)): money(GBP) { } // Minimum premium floor -MinPremium: money(GBP) { +MinPremium(): money(GBP) { £250 } @@ -44,13 +53,13 @@ Product(risk_score: number, num_properties: number): money(GBP) { where base = BasePremium(risk_score) * num_properties, discount = PropertyDiscount(num_properties, base), net = base - discount, - floor = max(net, MinPremium), + floor = if net > MinPremium() then net else MinPremium(), ipt = IPT(floor), - total = floor + ipt + AdminFee + total = floor + ipt + AdminFee() } // ISO code form works too -EuroExample: money(EUR) { +EuroExample(): money(EUR) { EUR1000 * 1.15 } @@ -60,12 +69,12 @@ IsAffordable(premium: money(GBP)): bool { } // Full breakdown -Breakdown(risk_score: number, num_properties: number) { +Breakdown(risk_score: number, num_properties: number): MoneyBreakdown { { base_premium: BasePremium(risk_score) * num_properties, discount: PropertyDiscount(num_properties, BasePremium(risk_score) * num_properties), - admin_fee: AdminFee, - ipt: IPT(Product(risk_score, num_properties) - AdminFee), + admin_fee: AdminFee(), + ipt: IPT(Product(risk_score, num_properties) - AdminFee()), total: Product(risk_score, num_properties), affordable: IsAffordable(Product(risk_score, num_properties)), } diff --git a/playground/src/examples/tradespeople.axiom.ts b/playground/src/examples/tradespeople.axiom.ts index 720fb4d..2da0141 100644 --- a/playground/src/examples/tradespeople.axiom.ts +++ b/playground/src/examples/tradespeople.axiom.ts @@ -14,13 +14,13 @@ type CoverOutcome = type ProductOutcome = offered { - covers: dict(CoverOutcome), + covers: list(CoverOutcome), total_gross_premium: money(GBP), total_net_premium: money(GBP), currency: string, } | declined { reasons: list(string) } - | referred { reasons: dict(string) } + | referred { reasons: list(string) } // --- Product-level adjustment factors (from CSV lookup tables) --- @@ -139,7 +139,7 @@ namespace PublicLiability { // --- Employers Liability (EL) --- namespace EmployersLiability { - // Fixed limit 10M. Premium per industry. Sole traders: insurable workers = max(0, manual - 1) + // Fixed limit 10M. Premium per industry. Sole traders exclude the proprietor. BasePremium(industry: string): money(GBP) { match industry { "DRI-103" => £137, @@ -150,7 +150,7 @@ namespace EmployersLiability { InsurableManualWorkers(manual_workers: number, business_type: string): number { if business_type == "sole_trader" - then max(0, manual_workers - 1) + then if manual_workers > 1 then manual_workers - 1 else 0 else manual_workers } @@ -161,7 +161,7 @@ namespace EmployersLiability { key: "EL", name: "Employers Liability", base_premium: bp * InsurableManualWorkers(manual_workers, business_type), - limit: £10000000, + limit: 10000000, excess: £0, } where bp = BasePremium(industry) @@ -325,7 +325,7 @@ Product( then declined { reasons: ["Manual workers cannot exceed total employees"] } else if number_of_claims > 1 then declined { reasons: ["Maximum 1 claim in last 5 years"] } - else if any not_available {} in covers + else if any not_available in covers then referred { reasons: collect not_available { reason } in covers => reason, } @@ -335,14 +335,20 @@ Product( total_net_premium: round(base_sum * adj * 0.65, 2), currency: "GBP", } - where covers = { - pl: PublicLiability.Rate(industry, limit: pl_limit, employees: number_of_employees), - el: EmployersLiability.Rate(industry, manual_workers, business_type), - pte: PortableTools.Rate(limit: pte_limit), - opm: OwnPlant.Rate(industry, limit: opm_limit, manual_workers), - hpm: HiredPlant.Rate(industry, limit: hpm_limit, manual_workers), - cw: ContractWorks.Rate(industry, limit: cw_limit, employees: number_of_employees), - }, + where pl_cover = PublicLiability.Rate(industry, limit: pl_limit, employees: number_of_employees), + el_cover = EmployersLiability.Rate(industry, manual_workers, business_type), + pte_cover = PortableTools.Rate(limit: pte_limit), + opm_cover = OwnPlant.Rate(industry, limit: opm_limit, manual_workers), + hpm_cover = HiredPlant.Rate(industry, limit: hpm_limit, manual_workers), + cw_cover = ContractWorks.Rate(industry, limit: cw_limit, employees: number_of_employees), + covers = [ + pl_cover, + el_cover, + pte_cover, + opm_cover, + hpm_cover, + cw_cover, + ], adj = Adjustments.Factor(postcode_group, years_experience, number_of_claims, years_since_last_claim), base_sum = sum(collect rated { base_premium } in covers => base_premium) } diff --git a/playground/src/lang/checker.ts b/playground/src/lang/checker.ts index 03a8efb..5ed6730 100644 --- a/playground/src/lang/checker.ts +++ b/playground/src/lang/checker.ts @@ -358,7 +358,11 @@ export function check(ast: ProgramNode, plugins?: import('./plugin').AxiomPlugin // Auto-resolve parameterless expression declarations const paramlessInfo = exprDecls.get(expr.name) ?? (currentCheckerNamespace ? exprDecls.get(`${currentCheckerNamespace}.${expr.name}`) : undefined); - if (paramlessInfo && paramlessInfo.decl.params.length === 0) { + if ( + paramlessInfo + && paramlessInfo.decl.kind === 'ExpressionDeclaration' + && paramlessInfo.decl.params.length === 0 + ) { const retType = paramlessInfo.returnType ?? inferType(paramlessInfo.decl.body, scope); if (retType) return setType(expr, retType); } diff --git a/src/Artifacts/ArtifactRepository.php b/src/Artifacts/ArtifactRepository.php new file mode 100644 index 0000000..94d25ff --- /dev/null +++ b/src/Artifacts/ArtifactRepository.php @@ -0,0 +1,12 @@ + $declarations + */ +final readonly class Program implements Node +{ + /** + * @param list $declarations + */ + public function __construct( + public array $declarations = [], + ) {} +} diff --git a/src/Conformance/ConformanceCase.php b/src/Conformance/ConformanceCase.php new file mode 100644 index 0000000..01530b1 --- /dev/null +++ b/src/Conformance/ConformanceCase.php @@ -0,0 +1,24 @@ + $artifacts + * @param array $input + */ +final readonly class ConformanceCase +{ + /** + * @param array $artifacts + * @param array $input + */ + public function __construct( + public string $name, + public string $source, + public string $expressionName, + public array $artifacts = [], + public array $input = [], + ) {} +} diff --git a/src/Diagnostics/Diagnostic.php b/src/Diagnostics/Diagnostic.php new file mode 100644 index 0000000..b130a81 --- /dev/null +++ b/src/Diagnostics/Diagnostic.php @@ -0,0 +1,14 @@ + $input + */ + public function evaluate(AnalyzedProgram $program, string $expressionName, array $input = []): Value; +} diff --git a/src/Extensions/Extension.php b/src/Extensions/Extension.php new file mode 100644 index 0000000..b848b8d --- /dev/null +++ b/src/Extensions/Extension.php @@ -0,0 +1,10 @@ + $input + * @return list + */ + public function validate(AnalyzedProgram $program, string $expressionName, array $input = []): array; +} diff --git a/src/Lexing/Lexer.php b/src/Lexing/Lexer.php new file mode 100644 index 0000000..40184df --- /dev/null +++ b/src/Lexing/Lexer.php @@ -0,0 +1,13 @@ + + */ + public function tokenize(string $file, string $source): array; +} diff --git a/src/Lexing/Token.php b/src/Lexing/Token.php new file mode 100644 index 0000000..9a16928 --- /dev/null +++ b/src/Lexing/Token.php @@ -0,0 +1,16 @@ + $tokens + */ + public function parse(array $tokens): ParsedProgram; +} diff --git a/src/Runtime/AnalyzedProgram.php b/src/Runtime/AnalyzedProgram.php new file mode 100644 index 0000000..978276b --- /dev/null +++ b/src/Runtime/AnalyzedProgram.php @@ -0,0 +1,22 @@ + $diagnostics + */ +final readonly class AnalyzedProgram +{ + /** + * @param list $diagnostics + */ + public function __construct( + public Program $program, + public array $diagnostics = [], + ) {} +} diff --git a/src/Runtime/Engine.php b/src/Runtime/Engine.php new file mode 100644 index 0000000..ed69e5c --- /dev/null +++ b/src/Runtime/Engine.php @@ -0,0 +1,16 @@ + $input + */ +final readonly class EvaluationRequest +{ + /** + * @param array $input + */ + public function __construct( + public string $expressionName, + public array $input = [], + ) {} +} diff --git a/src/Runtime/ParsedProgram.php b/src/Runtime/ParsedProgram.php new file mode 100644 index 0000000..4926481 --- /dev/null +++ b/src/Runtime/ParsedProgram.php @@ -0,0 +1,22 @@ + $diagnostics + */ +final readonly class ParsedProgram +{ + /** + * @param list $diagnostics + */ + public function __construct( + public ?Program $program, + public array $diagnostics = [], + ) {} +} diff --git a/src/Runtime/ProgramBundle.php b/src/Runtime/ProgramBundle.php new file mode 100644 index 0000000..330e963 --- /dev/null +++ b/src/Runtime/ProgramBundle.php @@ -0,0 +1,25 @@ + $sources + * @param list $extensions + */ +final readonly class ProgramBundle +{ + /** + * @param array $sources + * @param list $extensions + */ + public function __construct( + public array $sources, + public ArtifactRepository $artifacts, + public array $extensions = [], + ) {} +} diff --git a/src/Runtime/ResolvedProgram.php b/src/Runtime/ResolvedProgram.php new file mode 100644 index 0000000..ace49b6 --- /dev/null +++ b/src/Runtime/ResolvedProgram.php @@ -0,0 +1,22 @@ + $diagnostics + */ +final readonly class ResolvedProgram +{ + /** + * @param list $diagnostics + */ + public function __construct( + public Program $program, + public array $diagnostics = [], + ) {} +} diff --git a/src/Types/NumberType.php b/src/Types/NumberType.php index ba44496..87a009f 100644 --- a/src/Types/NumberType.php +++ b/src/Types/NumberType.php @@ -4,59 +4,10 @@ namespace Superscript\Axiom\Types; -use NumberFormatter; -use Superscript\Axiom\Exceptions\TransformValueException; -use Superscript\Monads\Option\Some; -use Superscript\Monads\Result\Err; -use Superscript\Monads\Result\Result; - -use function Psl\Str\before; -use function Psl\Type\num; -use function Psl\Type\numeric_string; -use function Psl\Type\string; -use function Superscript\Monads\Option\None; -use function Superscript\Monads\Option\Some; -use function Superscript\Monads\Result\Ok; - -/** - * @implements Type - */ -class NumberType implements Type +final readonly class NumberType implements Type { - public function assert(mixed $value): Result + public function describe(): string { - if (!num()->matches($value)) { - return new Err(new TransformValueException(type: 'numeric', value: $value)); - } - - return Ok(Some($value)); - } - - public function coerce(mixed $value): Result - { - if (is_string($value) && ($value === '' || $value === 'null')) { - return Ok(None()); - } - - return (match (true) { - numeric_string()->matches($value) || num()->matches($value) => Ok(num()->coerce($value)), - is_string($value) && numeric_string()->matches(before($value, '%')) => Ok(num()->coerce(before($value, '%')) / 100), - default => new Err(new TransformValueException(type: 'numeric', value: $value)), - })->map(fn(int|float $value) => Some($value)); - } - - /** - * @inheritDoc - */ - public function compare(mixed $a, mixed $b): bool - { - return $a === $b; - } - - public function format(mixed $value): string - { - $formatter = new NumberFormatter('en_GB', NumberFormatter::DECIMAL); - - return string()->assert($formatter->format($value)); + return 'number'; } } diff --git a/src/Types/Type.php b/src/Types/Type.php index dba6b11..4f30fff 100644 --- a/src/Types/Type.php +++ b/src/Types/Type.php @@ -4,39 +4,7 @@ namespace Superscript\Axiom\Types; -use Superscript\Monads\Option\Option; -use Superscript\Monads\Result\Result; -use Throwable; - -/** - * @template T = mixed - */ interface Type { - /** - * Assert that a value is of type T and return it wrapped in Option - * @param T $value - * @return Result, Throwable> - */ - public function assert(mixed $value): Result; - - /** - * Try to coerce a mixed value into type T - * @param mixed $value - * @return Result, Throwable> - */ - public function coerce(mixed $value): Result; - - /** - * @param T $a - * @param T $b - * @return bool - */ - public function compare(mixed $a, mixed $b): bool; - - /** - * @param T $value - * @return string - */ - public function format(mixed $value): string; + public function describe(): string; } diff --git a/src/Typing/TypeChecker.php b/src/Typing/TypeChecker.php new file mode 100644 index 0000000..432f513 --- /dev/null +++ b/src/Typing/TypeChecker.php @@ -0,0 +1,13 @@ +value; + } +} diff --git a/src/Values/Value.php b/src/Values/Value.php new file mode 100644 index 0000000..af585d6 --- /dev/null +++ b/src/Values/Value.php @@ -0,0 +1,10 @@ + 'Premium(): number { 42 }'], + artifacts: new class implements ArtifactRepository { + public function has(string $tableName): bool + { + return false; + } + + public function fetch(string $tableName): string + { + return ''; + } + }, + ); + + $request = new EvaluationRequest('Premium'); + $case = new ConformanceCase('smoke', 'Premium(): number { 42 }', 'Premium'); + $diagnostic = new Diagnostic( + DiagnosticSeverity::Info, + 'scaffold', + new SourceLocation('main.ax', 1, 1), + ); + $number = new NumberType(); + $value = new DecimalValue(BigDecimal::of('42')); + + self::assertArrayHasKey('main.ax', $bundle->sources); + self::assertSame('Premium', $request->expressionName); + self::assertSame('smoke', $case->name); + self::assertSame('scaffold', $diagnostic->message); + self::assertSame('number', $number->describe()); + self::assertTrue($value->unwrap()->isEqualTo(BigDecimal::of('42'))); + } +} From 6945b6aefd56dfdf16517389b3499dd4ee3450c9 Mon Sep 17 00:00:00 2001 From: Robert van Steen Date: Wed, 15 Apr 2026 15:37:50 +0200 Subject: [PATCH 3/3] Refine Axiom v1 spec syntax --- axiom-v1-spec.md | 103 +++++++++++++++++++++-------------------------- 1 file changed, 45 insertions(+), 58 deletions(-) diff --git a/axiom-v1-spec.md b/axiom-v1-spec.md index 5d2e6ba..9224f06 100644 --- a/axiom-v1-spec.md +++ b/axiom-v1-spec.md @@ -67,7 +67,7 @@ Axiom v1 does not provide: - implicit IO or side effects - dynamic maps or dictionary types - indexing (`xs[0]`, `record["field"]`) -- hidden type coercions +- type coercions - `null` or optional/nullable types (`T?`) - syntax-extending plugins - string interpolation or free-form text templating @@ -98,14 +98,21 @@ BasePremium(sum_insured: number, rate: number): number { } ``` -Zero-argument expressions are written explicitly with empty parentheses: +Zero-argument expressions omit the parameter list entirely: ```axiom -AdminFee(): number { +AdminFee: number { 35 } ``` +At use sites, zero-argument expressions are referenced by name, not called with +empty parentheses: + +```axiom +AdminFee +``` + Parameters may use inline record shapes: ```axiom @@ -127,13 +134,15 @@ interfaces explicit. ### 3.2 Type Declarations +Type declarations bind a name directly to a type body. They do not use `=`. + #### Variant types A variant type is a closed set of tagged alternatives. Each alternative has a tag and an optional record payload. ```axiom -type CoverOutcome = +type CoverOutcome rated { key: string, name: string, @@ -145,7 +154,7 @@ type CoverOutcome = Payload-less variants omit the payload: ```axiom -type Status = active | suspended | cancelled +type Status active | suspended | cancelled ``` #### Record types @@ -153,7 +162,7 @@ type Status = active | suspended | cancelled A record type declares a fixed set of named fields: ```axiom -type Exposure = { +type Exposure { industry: string, turnover: number, employees: number, @@ -201,7 +210,7 @@ table industry_config: list({ Or, using a named record type: ```axiom -type IndustryRow = { +type IndustryRow { code: string, base_rate: number, min_premium: number, @@ -267,9 +276,9 @@ There is no core map or dictionary type in v1. Named record types and named variant types are nominal. ```axiom -type Exposure = { industry: string, turnover: number } +type Exposure { industry: string, turnover: number } -type Decision = +type Decision approved { premium: number } | declined ``` @@ -335,22 +344,6 @@ rejected. Arithmetic on `non_zero` values usually produces plain `number` unless another rule proves a refined result. -### 4.8 Coercion - -Axiom supports explicit coercion with `as`, but only for conversions that are total -and specified. - -Core coercions: - -| From | To | -|------|----| -| `number` | `string` | - -Core Axiom v1 does not define `string as number`. - -Extensions may register additional total coercions. A coercion may not silently -invent a domain value for invalid input. - --- ## 5. Expressions @@ -366,7 +359,6 @@ All computation in Axiom is expressed through expressions. There are no statemen true false [1, 2, 3] -{ code: "DRI-945", rate: 0.85 } ``` Numeric literals other than `0` are assignable to `non_zero`. @@ -375,6 +367,7 @@ Numeric literals other than `0` are assignable to `non_zero`. ```axiom turnover +AdminFee exposure.industry row.base_rate ``` @@ -475,7 +468,7 @@ The wildcard arm is required. ### 5.6 Expression Calls -Expression calls use one of two forms: +Parameterized expressions are called with one of two forms: - positional arguments - named arguments @@ -483,6 +476,9 @@ Expression calls use one of two forms: Calls may also mix the forms, with one restriction: positional arguments must come before named arguments. +Zero-argument expressions are not called. They are referenced directly by their +name or qualified name. + Positional example: ```axiom @@ -507,6 +503,12 @@ Qualified calls into namespaces: Industry.BaseRate(industry) ``` +Zero-argument namespace members are also referenced directly: + +```axiom +Pricing.AdminFee +``` + ### 5.7 Variant Construction Variant values are constructed with their tag name: @@ -574,13 +576,7 @@ round(total, 2) Bindings are independent definitions inside the current scope. Their evaluation order is determined by demand and data dependency, not by textual order alone. -### 5.11 Coercion - -```axiom -42 as string -``` - -### 5.12 Parenthesized Expressions +### 5.11 Parenthesized Expressions Parentheses override precedence: @@ -598,7 +594,7 @@ From lowest to highest precedence: | Precedence | Operators | Associativity | |------------|-----------|---------------| -| 1 | `||` | left | +| 1 | `\|\|` | left | | 2 | `&&` | left | | 3 | `==`, `!=` | left | | 4 | `<`, `>`, `<=`, `>=`, `in`, `not in` | left | @@ -642,7 +638,7 @@ Extensions may define additional operator rules for extension-defined types. | Operator | Left | Right | Result | |----------|------|-------|--------| | `&&` | `bool` | `bool` | `bool` | -| `||` | `bool` | `bool` | `bool` | +| `\|\|` | `bool` | `bool` | `bool` | ### 6.6 Membership Operators @@ -866,8 +862,8 @@ The type checker infers types bottom-up. | `[1, 2, 3]` | `list(non_zero)` | | `{ a: 1, b: "x" }` | inline record shape `{ a: non_zero, b: string }` | | `identifier` | declared type from scope | +| `QualifiedName` | declared return type of a zero-argument expression | | `object.field` | field type from record shape | -| `expr as type` | target type | | `left OP right` | operator result type | | `if/then/else` | common branch type | | `match` | common arm type | @@ -893,6 +889,7 @@ If a tag is ambiguous, qualification is required. For each call, the type checker validates: - the callee exists +- the referenced expression declares one or more parameters - the argument ordering is valid - positional arity or named parameter completeness - argument types are assignable to parameter types @@ -937,7 +934,6 @@ dependencies. Mutual recursion and self-recursion are type errors in v1. The checker enforces: - operator type validity -- coercion validity - member access validity - match exhaustiveness for variant subjects - match arm type consistency @@ -1047,7 +1043,8 @@ declaration = type_decl | namespace_decl | table_decl | expr_decl ; (* --- Declarations --- *) -type_decl = "type" UPPER_IDENT "=" ( record_shape | variant_alts ) ; +type_decl = "type" UPPER_IDENT type_decl_body ; +type_decl_body = record_shape | variant_alts | extension_type ; record_shape = "{" field_decl { "," field_decl } [ "," ] "}" ; variant_alts = variant_alt { "|" variant_alt } ; variant_alt = LOWER_IDENT [ record_shape ] ; @@ -1059,7 +1056,9 @@ namespace_member = type_decl | expr_decl ; table_decl = "table" LOWER_IDENT ":" "list" "(" table_row_type ")" ; table_row_type = record_shape | qualified_upper ; -expr_decl = UPPER_IDENT "(" [ param_list ] ")" ":" type_expr +expr_decl = zero_arg_expr_decl | param_expr_decl ; +zero_arg_expr_decl = UPPER_IDENT ":" type_expr "{" expression "}" ; +param_expr_decl = UPPER_IDENT "(" param_list ")" ":" type_expr "{" expression "}" ; param_list = param { "," param } ; param = LOWER_IDENT ":" type_expr ; @@ -1094,7 +1093,7 @@ comparison_expr = additive_expr additive_expr = multiplicative_expr { ( "+" | "-" ) multiplicative_expr } ; multiplicative_expr = unary_expr { ( "*" | "/" ) unary_expr } ; unary_expr = ( "not" | "!" | "-" ) unary_expr | postfix_expr ; -postfix_expr = primary { "." LOWER_IDENT | "as" type_expr } ; +postfix_expr = primary { "." LOWER_IDENT } ; primary = if_expr | match_expr @@ -1204,7 +1203,7 @@ type namespace table if then else match in any all collect -where as +where not true false sum product ``` @@ -1223,12 +1222,12 @@ _ The following example uses only core v1 features. ```axiom -type Exposure = { +type Exposure { industry: string, turnover: number, } -type CoverOutcome = +type CoverOutcome rated { key: string, name: string, @@ -1236,7 +1235,7 @@ type CoverOutcome = } | not_available { reason: string } -type ProductOutcome = +type ProductOutcome offered { covers: list(CoverOutcome), total: number, @@ -1302,7 +1301,6 @@ An extension may add: - custom types - operator rules for those types - intrinsic overloads for those types -- total coercions involving those types An extension may not add: @@ -1360,7 +1358,7 @@ extension. It is not part of the core language. type. ```axiom -type Premium = money(GBP) +type Premium money(GBP) ``` ### 16.2 Literals @@ -1398,16 +1396,6 @@ The standard money extension overloads: - `sum` - `product` when explicitly defined by the implementation -### 16.6 Coercion - -The standard money extension may define total coercions such as: - -```axiom -150 as money(GBP) -``` - -It does not define silent parsing of arbitrary invalid strings. - --- ## 17. Summary @@ -1422,7 +1410,6 @@ Its core consists of: - `if`, `match`, and `where` - list-oriented collection forms - exact decimal numbers with static division safety -- explicit, total-only coercions - a narrow extension model for value and type families Axiom v1 does not include: