Skip to content

Commit e47c433

Browse files
committed
Finalize boxed contract runtime migration
1 parent b552f86 commit e47c433

29 files changed

Lines changed: 615 additions & 320 deletions

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ These standards represent the user's preferred style and architectural philosoph
3434
## 4. Architectural Patterns
3535
- **Decoder Pattern:** `JsonSource -> struct('T * JsonSource)`
3636
- **Encoder Pattern:** `JsonWriter -> 'T -> unit`
37-
- **Pipeline Blueprint:** Use `Schema.define<'T> |> Schema.construct ctor |> Schema.field ... |> Schema.build` to define symmetric mappings. This is the current stable DSL.
37+
- **Pipeline Blueprint:** Use `Schema.record ctor |> Schema.field ... |> Schema.build` to define symmetric mappings. This is the current stable DSL.
3838

3939
## 5. Current Findings & Edge Cases
40-
- **Do not collapse `Schema.define` and `Schema.construct` without proving it compiles across the repo.** A direct `Schema.define makeCtor` style was attempted and rejected because F# either mis-inferred record targets when field names overlapped (`Id`, `Name`) or collapsed the constructor state to `obj`.
40+
- **Keep `Schema.record` as the stable entry point.** Earlier experiments that split record initialization into separate entry points or tried to collapse everything into one differently shaped helper ran into F# inference failures around overlapping record fields such as `Id` and `Name`.
4141
- **Keep `Json.compile` explicit.** Hiding compilation inside `serialize`/`deserialize` would either recompile on each call or require implicit caching, which is poor UX for a performance-oriented library.
4242
- **Explicit nested/custom schemas currently use `Schema.fieldWith`.** Auto-resolution exists for primitives, lists, and arrays only. Future work may rename this, but the explicit-schema distinction is currently meaningful.
4343
- **Benchmarks should use the same DSL as tests and docs.** Avoid introducing parallel schema-definition styles unless the repo deliberately adopts a second public API.

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,15 @@ The project ships both a manual scenario runner and a repeatable `perf` workflow
105105
- profiling guide: [docs/HOW_TO_PROFILE_BENCHMARK_HOT_PATHS.md](docs/HOW_TO_PROFILE_BENCHMARK_HOT_PATHS.md)
106106
- full benchmark page: [docs/BENCHMARKS.md](docs/BENCHMARKS.md)
107107

108-
Latest local manual snapshot, measured on March 31, 2026:
108+
Latest local manual snapshot, measured on April 2, 2026:
109109

110110
| Scenario | CodecMapper serialize | STJ serialize | CodecMapper deserialize | STJ deserialize | Takeaway |
111111
| --- | ---: | ---: | ---: | ---: | --- |
112-
| `small-message` | `526.5 ns` | `715.0 ns` | `641.6 ns` | `907.5 ns` | `CodecMapper` still leads both directions on the tiny-message case. |
113-
| `person-batch-25` | `8.33 us` | `7.37 us` | `24.29 us` | `18.22 us` | Medium nested workloads still trail `STJ`, but remain comfortably ahead of `Newtonsoft.Json`. |
114-
| `person-batch-250` | `84.95 us` | `69.09 us` | `229.36 us` | `168.44 us` | Larger nested batches still trail `STJ`, but stay ahead of `Newtonsoft.Json`. |
115-
| `escaped-articles-20` | `43.45 us` | `30.85 us` | `98.00 us` | `59.33 us` | String-heavy payloads remain a clear weak spot, especially on decode. |
116-
| `telemetry-500` | `413.04 us` | `298.93 us` | `516.87 us` | `513.80 us` | Numeric-heavy decode is still roughly tied with `STJ`, while serialize trails. |
117-
| `person-batch-25-unknown-fields` | `9.05 us` | `7.85 us` | `30.93 us` | `25.00 us` | Unknown-field decode is still in range, but not especially close to `STJ`. |
112+
| `small-message` | `441.8 ns` | `627.1 ns` | `644.6 ns` | `889.1 ns` | `CodecMapper` still leads both directions on the tiny-message case. |
113+
| `person-batch-25` | `7.96 us` | `7.29 us` | `27.13 us` | `20.84 us` | Medium nested workloads still trail `STJ`, but remain ahead of `Newtonsoft.Json` on decode. |
114+
| `person-batch-250` | `83.89 us` | `71.53 us` | `294.50 us` | `217.70 us` | Larger nested batches still trail `STJ`, and the decode gap widened on this run. |
115+
| `escaped-articles-20` | `45.97 us` | `34.71 us` | `115.38 us` | `66.25 us` | String-heavy payloads remain a clear weak spot, especially on decode. |
116+
| `telemetry-500` | `421.70 us` | `317.53 us` | `559.46 us` | `556.73 us` | Numeric-heavy decode is still roughly tied with `STJ`, while serialize trails. |
117+
| `person-batch-25-unknown-fields` | `8.56 us` | `7.63 us` | `34.38 us` | `29.37 us` | Unknown-field decode is still in range, but not especially close to `STJ`. |
118118

119119
Those numbers are machine-specific. Compare ratios and workload shape more than the absolute values.

TASKS.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
Active work only. Historical completed work lives in [notes/AGENT_NOTES.md](notes/AGENT_NOTES.md) and [AGENTS.md](AGENTS.md).
44

5-
- [ ] **Task 42: Delete the old boxed schema engine completely**
6-
- Remove `ISchema`, `SchemaDefinition`, `SchemaField`, and `obj[] -> obj` record construction from the active implementation.
5+
- [x] **Task 42: Finalize the core boxed contract runtime**
6+
- Remove `ISchema`, `SchemaDefinition`, `SchemaField`, and `obj[] -> obj` record construction from the active core implementation.
77
- Move the public authored DSL to `Schema.*` over `Schema<'T>`.
88
- Delete the old schema DSL instead of keeping compatibility shims.
99
- Retarget `Json`, `Xml`, `Yaml`, `KeyValue`, and `JsonSchema` to compile from the new boxed contract IR directly.
1010
- Remove any `Lowering.lower` or legacy boxed-schema bridge from the runtime path.
11-
- Update tests, benchmarks, bridge code, and docs to the new surface.
12-
- Completion bar: there is no old boxed DSL left, publicly or internally.
11+
- Update tests, benchmarks, and docs to the new surface.
12+
- Explicitly exclude bridge internals from this task. Bridge cleanup can follow once the core runtime shape is stable.
13+
- Completion bar: there is no old boxed DSL left in the core runtime path or public authored surface.
1314

1415
- [ ] **Task 49: Review and improve the new DSL for DX**
1516
- After Task 42, review the new `Schema.*` surface for compactness, clarity, and maintainability.

benchmarks/BenchmarkScenarios.fs

Lines changed: 28 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -659,55 +659,50 @@ module TypedJsonExperiment =
659659

660660
module Schemas =
661661
let address =
662-
Schema.define<Address>
663-
|> Schema.construct (fun street city -> { Street = street; City = city })
664-
|> Schema.field "Street" _.Street
665-
|> Schema.field "City" _.City
662+
Schema.record (fun street city -> { Street = street; City = city })
663+
|> Schema.field "Street" (fun (address: Address) -> address.Street)
664+
|> Schema.field "City" (fun (address: Address) -> address.City)
666665
|> Schema.build
667666

668667
let person =
669-
Schema.define<Person>
670-
|> Schema.construct (fun id name home -> { Id = id; Name = name; Home = home })
671-
|> Schema.field "Id" _.Id
672-
|> Schema.field "Name" _.Name
673-
|> Schema.fieldWith "Home" _.Home address
668+
Schema.record (fun id name home -> { Id = id; Name = name; Home = home })
669+
|> Schema.field "Id" (fun (person: Person) -> person.Id)
670+
|> Schema.field "Name" (fun (person: Person) -> person.Name)
671+
|> Schema.fieldWith "Home" (fun (person: Person) -> person.Home) address
674672
|> Schema.build
675673

676674
let smallMessage =
677-
Schema.define<SmallMessage>
678-
|> Schema.construct (fun id kind success traceId -> {
675+
Schema.record (fun id kind success traceId -> {
679676
Id = id
680677
Kind = kind
681678
Success = success
682679
TraceId = traceId
683680
})
684-
|> Schema.field "Id" _.Id
685-
|> Schema.field "Kind" _.Kind
686-
|> Schema.field "Success" _.Success
687-
|> Schema.field "TraceId" _.TraceId
681+
|> Schema.field "Id" (fun (message: SmallMessage) -> message.Id)
682+
|> Schema.field "Kind" (fun (message: SmallMessage) -> message.Kind)
683+
|> Schema.field "Success" (fun (message: SmallMessage) -> message.Success)
684+
|> Schema.field "TraceId" (fun (message: SmallMessage) -> message.TraceId)
688685
|> Schema.build
689686

690687
let article =
691-
Schema.define<Article>
692-
|> Schema.construct (fun id slug title body tags author -> {
688+
Schema.record (fun id slug title body tags author -> {
693689
Id = id
694690
Slug = slug
695691
Title = title
696692
Body = body
697693
Tags = tags
698694
Author = author
699695
})
700-
|> Schema.field "Id" _.Id
701-
|> Schema.field "Slug" _.Slug
702-
|> Schema.field "Title" _.Title
703-
|> Schema.field "Body" _.Body
704-
|> Schema.field "Tags" _.Tags
705-
|> Schema.fieldWith "Author" _.Author person
696+
|> Schema.field "Id" (fun (article: Article) -> article.Id)
697+
|> Schema.field "Slug" (fun (article: Article) -> article.Slug)
698+
|> Schema.field "Title" (fun (article: Article) -> article.Title)
699+
|> Schema.field "Body" (fun (article: Article) -> article.Body)
700+
|> Schema.field "Tags" (fun (article: Article) -> article.Tags)
701+
|> Schema.fieldWith "Author" (fun (article: Article) -> article.Author) person
706702
|> Schema.build
707703

708704
let telemetryPoint =
709-
Schema.define<TelemetryPoint>
710-
|> Schema.construct (fun sensorId timestamp temperature humidity voltage retryCount sequence healthy -> {
705+
Schema.record (fun sensorId timestamp temperature humidity voltage retryCount sequence healthy -> {
711706
SensorId = sensorId
712707
Timestamp = timestamp
713708
Temperature = temperature
@@ -717,14 +712,14 @@ module Schemas =
717712
Sequence = sequence
718713
Healthy = healthy
719714
})
720-
|> Schema.field "SensorId" _.SensorId
721-
|> Schema.field "Timestamp" _.Timestamp
722-
|> Schema.field "Temperature" _.Temperature
723-
|> Schema.field "Humidity" _.Humidity
724-
|> Schema.field "Voltage" _.Voltage
725-
|> Schema.field "RetryCount" _.RetryCount
726-
|> Schema.field "Sequence" _.Sequence
727-
|> Schema.field "Healthy" _.Healthy
715+
|> Schema.field "SensorId" (fun (point: TelemetryPoint) -> point.SensorId)
716+
|> Schema.field "Timestamp" (fun (point: TelemetryPoint) -> point.Timestamp)
717+
|> Schema.field "Temperature" (fun (point: TelemetryPoint) -> point.Temperature)
718+
|> Schema.field "Humidity" (fun (point: TelemetryPoint) -> point.Humidity)
719+
|> Schema.field "Voltage" (fun (point: TelemetryPoint) -> point.Voltage)
720+
|> Schema.field "RetryCount" (fun (point: TelemetryPoint) -> point.RetryCount)
721+
|> Schema.field "Sequence" (fun (point: TelemetryPoint) -> point.Sequence)
722+
|> Schema.field "Healthy" (fun (point: TelemetryPoint) -> point.Healthy)
728723
|> Schema.build
729724

730725
let personList = Schema.list person

benchmarks/CodecMapper.Benchmarks/CodecMapperBench.fs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,16 @@ type Person = { Id: int; Name: string; Home: Address }
1010

1111
module Schemas =
1212
let address =
13-
Schema.define<Address>
14-
|> Schema.construct (fun street city -> { Street = street; City = city })
15-
|> Schema.field "Street" _.Street
16-
|> Schema.field "City" _.City
13+
Schema.record (fun street city -> { Street = street; City = city })
14+
|> Schema.field "Street" (fun (address: Address) -> address.Street)
15+
|> Schema.field "City" (fun (address: Address) -> address.City)
1716
|> Schema.build
1817

1918
let person =
20-
Schema.define<Person>
21-
|> Schema.construct (fun id name home -> { Id = id; Name = name; Home = home })
22-
|> Schema.field "Id" _.Id
23-
|> Schema.field "Name" _.Name
24-
|> Schema.fieldWith "Home" _.Home address
19+
Schema.record (fun id name home -> { Id = id; Name = name; Home = home })
20+
|> Schema.field "Id" (fun (person: Person) -> person.Id)
21+
|> Schema.field "Name" (fun (person: Person) -> person.Name)
22+
|> Schema.fieldWith "Home" (fun (person: Person) -> person.Home) address
2523
|> Schema.build
2624

2725
///

src/CodecMapper.Bridge/Bridge.fs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,24 @@ type private ConstructionPlan =
6464
type private ImportedSchema<'T>(fields: FieldRuntime list, createObj: obj[] -> obj) =
6565
inherit Schema<'T>()
6666

67-
interface IMappingDefinitionRuntime with
67+
///
68+
/// Bridge imports still depend on CLR-oriented construction plans, but the
69+
/// active runtime only understands the boxed record-state contract now.
70+
interface IRecordRuntime with
6871
member _.FieldsRuntime = fields
69-
member _.CreateObj(values) = createObj values
72+
member _.CreateStateObj() = box (Array.zeroCreate<obj> fields.Length)
73+
74+
member _.StoreFieldObj(state, index, value) =
75+
let values = unbox<obj array> state
76+
values[index] <- value
77+
78+
member _.CompleteObj(state) =
79+
let values = unbox<obj array> state
80+
createObj values
81+
82+
member _.ReleaseStateObj(state) =
83+
let values = unbox<obj array> state
84+
System.Array.Clear(values, 0, values.Length)
7085

7186
type private SchemaFactory =
7287
static member CreateImported<'T>(fields: FieldRuntime list, createFunc: Func<obj[], obj>) : Schema<'T> =

0 commit comments

Comments
 (0)