From 77d5682ced6ef20fb4c327bfcf581c3e069bbc43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Villase=C3=B1or=20Montfort?= <195970+montfort@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:20:48 -0600 Subject: [PATCH] =?UTF-8?q?feat(m3):=20siembra=20de=20r=C3=A9plica=20cross?= =?UTF-8?q?-engine=20=E2=80=94=20IDeterministicSeeding=20+=20peer=5Fid=20d?= =?UTF-8?q?e=20Loro=20(CHARTER-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra FU-016. Promueve la siembra de identidad de réplica de capacidad concreta de YrsEngine (CHARTER-09) a capacidad cross-engine, y añade el equivalente de Loro. Segundo de tres Charters del vaciado del backlog: 3 open → 2. Esfuerzo S (no el M del registro: las incógnitas que lo justificaban se verificaron en las fuentes). La investigación descartó dos premisas del follow-up: - El «gate Loro↔referencia» NO es construible: loro-crdt de npm es un build wasm del MISMO core Rust, así que compararlo con nuestro loro nativo es tautológico. Se implementa un gate de AUTO-DETERMINISMO (cross-run/cross-RID con peer_id fijo), testigo de regresión, no prueba de paridad. - Un CreateDoc(ulong) en ICrdtEngine filtraría la abstracción por la asimetría de dominios (yrs <2^53, Loro todo u64 salvo MAX). Se usa una capacidad opcional IDeterministicSeeding que hace del dominio parte del contrato (MaxReplicaIdExclusive), espejando NativeVersioning → INativeVersioning?. Shim de Loro: weft_loro_doc_new_with_peer_id con guard de u64::MAX en la frontera, catch_unwind, ABI v2→v3. mem_asan cubre la fn nueva. El HeaderBindingParityTests de CHARTER-12 validó el bump de ABI y la firma nueva SIN tocarlo — el pago del orden 12→13. .NET: IDeterministicSeeding + ICrdtEngine.DeterministicSeeding + Yrs/Loro seeding + binding + resolver v3. Gate: golden-loro.json + Loro_seeded_export_matches_golden (+ estabilidad cross-run). record_timestamp=false verificado en loro 1.13.6 (sin él el gate sería imposible); se confía en el default, con el golden como red de seguridad (R3). El relay/broker NO se tocan: sembrar peer_id es footgun en producción (documentado en el XML doc). Verificado: 151/151 tests, ASan 0 fugas sobre la fn nueva, golden yrs↔Yjs intacto (CHARTER-09 sin regresión). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-16-002-charter-13-siembra-cross-engine.md | 111 +++++++++++ .../charters/13-siembra-cross-engine.md | 181 ++++++++++++++++++ .straymark/follow-ups-backlog.md | 8 +- native/weft-loro-ffi/include/weft_loro_ffi.h | 7 +- native/weft-loro-ffi/src/lib.rs | 38 +++- native/weft-loro-ffi/tests/mem_asan.rs | 33 +++- src/Weft.Core/Abstractions/ICrdtEngine.cs | 6 + .../Abstractions/IDeterministicSeeding.cs | 37 ++++ src/Weft.Core/Yrs/YrsDeterministicSeeding.cs | 18 ++ src/Weft.Core/Yrs/YrsEngine.cs | 13 +- .../Interop/NativeLibraryResolver.cs | 2 +- src/Weft.Loro/Interop/NativeMethods.cs | 3 + src/Weft.Loro/LoroDeterministicSeeding.cs | 18 ++ src/Weft.Loro/LoroDoc.cs | 6 + src/Weft.Loro/LoroEngine.cs | 3 + tests/Weft.Core.Tests/DocumentBrokerTests.cs | 1 + .../DeterminismTests.cs | 86 +++++++++ .../Weft.Determinism.Tests.csproj | 1 + .../DeterministicSeedingTests.cs | 87 +++++++++ tests/determinism-yjs/golden-loro.json | 5 + 20 files changed, 651 insertions(+), 13 deletions(-) create mode 100644 .straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-002-charter-13-siembra-cross-engine.md create mode 100644 .straymark/charters/13-siembra-cross-engine.md create mode 100644 src/Weft.Core/Abstractions/IDeterministicSeeding.cs create mode 100644 src/Weft.Core/Yrs/YrsDeterministicSeeding.cs create mode 100644 src/Weft.Loro/LoroDeterministicSeeding.cs create mode 100644 tests/Weft.Versioning.Tests/DeterministicSeedingTests.cs create mode 100644 tests/determinism-yjs/golden-loro.json diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-002-charter-13-siembra-cross-engine.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-002-charter-13-siembra-cross-engine.md new file mode 100644 index 0000000..609e8ae --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-002-charter-13-siembra-cross-engine.md @@ -0,0 +1,111 @@ +--- +id: AILOG-2026-07-16-002 +title: "CHARTER-13: siembra de réplica cross-engine — IDeterministicSeeding + peer_id de Loro (ABI v3) y gate de auto-determinismo" +status: accepted +created: 2026-07-16 +agent: claude-opus-4-8 +confidence: high +review_required: true +risk_level: low +eu_ai_act_risk: not_applicable +nist_genai_risks: [] +iso_42001_clause: [] +observability_scope: none +tags: [follow-ups, cross-engine, deterministic-seeding, loro, peer-id, abi-bump, determinism-gate, ffi-boundary] +related: [AILOG-2026-07-16-001, AILOG-2026-07-15-001] +originating_charter: CHARTER-13-siembra-cross-engine +--- + +# AILOG: CHARTER-13 — siembra de réplica cross-engine (FU-016) + +## Summary + +Despacho de FU-016: promueve la siembra de identidad de réplica de capacidad concreta de `YrsEngine` +(CHARTER-09) a capacidad cross-engine, y añade el equivalente de Loro. Segundo de tres Charters que +vacían el backlog antes del publish; baja de 3 open a 2 (FU-010, FU-015). Esfuerzo **S**, no el M que +declaraba el registro: las dos incógnitas que justificaban M (API real de `set_peer_id`, default de +`record_timestamp`) se resolvieron verificándolas en las fuentes pinneadas antes de escribir código. + +La investigación descartó dos premisas del follow-up: +- **El gate «Loro↔referencia» no es construible.** `loro-crdt` de npm es un build wasm del mismo core + Rust; compararlo con nuestro `loro` nativo es tautológico. El gate yrs↔Yjs es significativo porque Yjs + y yrs son implementaciones genuinamente independientes; Loro no tiene contraparte. El gate realizable + es de **auto-determinismo** (cross-run/cross-RID con peer_id fijo), no de paridad. +- **Un `CreateDoc(ulong)` en `ICrdtEngine` filtraría la abstracción** por la asimetría de dominios + (yrs `< 2^53`, Loro todo `u64` salvo `MAX`) — el llamador tendría que ramificar por motor. Se usó una + capacidad opcional, `IDeterministicSeeding`, que hace del dominio parte del contrato. + +## Actions Performed + +1. **Shim de Loro — `weft_loro_doc_new_with_peer_id` (ABI v2→v3)**: guard de `u64::MAX` en la frontera + (espejo del guard de rango de yrs) → `WEFT_ERR_OUT_OF_BOUNDS`; `catch_unwind` como toda entrada. + `mem_asan.rs`: assert ABI v3 + `seeded_peer_id_reachable_guarded_and_nonleaking` (camino feliz + + valor reservado + out nulo). Header y su comentario de ABI actualizados. +2. **`IDeterministicSeeding`** (nuevo, `Weft.Core.Abstractions`): `ulong MaxReplicaIdExclusive` + + `ICrdtDoc CreateDoc(ulong replicaId)`. Añadida a `ICrdtEngine.DeterministicSeeding` (`null` si no se + soporta). Espeja el patrón `NativeVersioning → INativeVersioning?`. Nombre `replicaId` (neutral; + `client_id`/`peer_id` son dialectos). +3. **`YrsDeterministicSeeding`** (`Max = 1<<53`, delega en `CreateDoc(ulong)` existente) + + **`LoroDeterministicSeeding`** (`Max = ulong.MaxValue`) + binding + `LoroDoc.Create(ulong)` + + `NativeLibraryResolver.ExpectedAbiVersion = 3`. XML doc obsoleto de `YrsEngine.CreateDoc(ulong)` + («se difiere a un follow-up») corregido. +4. **`TrackingEngine`** de los tests actualizado (`DeterministicSeeding => null`) — implementador de + `ICrdtEngine` que había que cubrir al añadir el miembro. +5. **Gate de auto-determinismo** (`DeterminismTests`): `Loro_seeded_export_matches_golden` (ascii + + unicode) contra `golden-loro.json` nuevo + `Loro_seeded_export_is_stable_across_runs` (la premisa del + golden). `Weft.Determinism.Tests` gana ProjectReference a `Weft.Loro`. Golden bootstrappeado con un + fact temporal (ya eliminado) que volcó los hashes exactos. Documentado como **testigo de regresión, + no paridad** en el `_comment` del JSON y el doc-comment del test. +6. **Tests de la capacidad** (`DeterministicSeedingTests`): ambos motores exponen la capacidad; sus + `MaxReplicaIdExclusive` (fija la asimetría que justificó el diseño); guard de rango en ambos + (`u64::MAX` en Loro, `1<<53` en yrs → `ArgumentOutOfRangeException`); convergencia con un par no + sembrado; estabilidad del export de Loro con el mismo/distinto peer_id. +7. **Pago del orden 12→13**: `HeaderBindingParityTests` (de CHARTER-12) validó automáticamente la + función nueva y el bump de ABI **sin tocarlo** — 6/6 verde. Era exactamente la razón de hacer el 12 + antes. + +## Risk + +Riesgos del Charter (R1–R6) y su desenlace: + +- **R1 (gate «Loro↔referencia» no construible)** — confirmado y evitado: se implementó el de + auto-determinismo, documentado como testigo de regresión. +- **R2 (golden leído como paridad)** — mitigado: el `_comment` y el doc-comment lo dicen literalmente. +- **R3 (`record_timestamp` cambia de default → auto-determinismo muere en silencio)** — decisión tomada: + **NO se aserta el config explícitamente en el shim; se confía en el default (`false`, verificado en + loro 1.13.6).** Razón: (a) el gate lo caza al bumpear —el golden cambiaría—, que es el R16 funcionando; + (b) asertar el config duplicaría la verdad (una en el default de loro, otra en el shim) y crearía una + divergencia que mantener. La red de seguridad es el golden + el pin exacto de versión. Si un bump + futuro pusiera `record_timestamp=true`, el golden divergiría y el gate obligaría a investigar antes de + regenerarlo — que es el comportamiento correcto. +- **R4 (footgun de siembra en producción)** — mitigado: el XML doc de `IDeterministicSeeding` lo dice; el + relay/broker NO se tocaron; ningún test cablea la siembra en un escenario multi-cliente. +- **R5 (ABI v3 vs `.so` cacheado v2)** — el resolver rechaza con excepción clara (su trabajo); el flujo + recompila el nativo primero. Sin incidencia. +- **R6 (promover tensa P-IV)** — evitado con el patrón de capacidad opcional; el test asierta la forma + (`DeterministicSeeding` no null en ambos), como `LoroNativeVersioningTests` hace con `NativeVersioning`. + +Sin R7: no surgió ningún riesgo nuevo durante la ejecución. + +## Follow-ups + +Ninguno nuevo. FU-016 cerrado, con su premisa corregida (gate de auto-determinismo, no de paridad) en la +nota de cierre del registro. + +**Nota de drift (no accionable): falso positivo conocido de `charter drift` con `.csproj`.** El drift +reporta `tests/Weft.Determinism.Tests/Weft.Determinism.Tests.csproj` como «modified but not declared» +pese a estar declarado en `## Files to modify`. Es el bug de parser de StrayMark #354 (el matcher no +casa rutas `.csproj`/`.sln`), ya corroborado en CHARTER-06. El archivo está declarado y su cambio (la +ProjectReference a `Weft.Loro`) es intencional; no hay deriva real. + +## Verification + +```bash +cargo build --release --features test-hooks --manifest-path native/Cargo.toml +dotnet test Weft.sln -c Release # 151/151 (Determinism 4→8, Versioning 36→45) +cargo test --features test-hooks --manifest-path native/weft-loro-ffi/Cargo.toml # 7/7 (+ seeded_peer_id) +RUSTFLAGS="-Zsanitizer=address" cargo +nightly test --features test-hooks \ + --target x86_64-unknown-linux-gnu --manifest-path native/weft-loro-ffi/Cargo.toml # 0 fugas sobre la fn nueva +dotnet test tests/Weft.Versioning.Tests -c Release --filter "FullyQualifiedName~HeaderBindingParity" # 6/6: valida ABI v3 +cd tests/determinism-yjs && npm test # golden yrs↔Yjs intacto (CHARTER-09 sin regresión) +``` diff --git a/.straymark/charters/13-siembra-cross-engine.md b/.straymark/charters/13-siembra-cross-engine.md new file mode 100644 index 0000000..035f4c9 --- /dev/null +++ b/.straymark/charters/13-siembra-cross-engine.md @@ -0,0 +1,181 @@ +--- +charter_id: CHARTER-13-siembra-cross-engine +status: in-progress +effort_estimate: S +trigger: "El operador decide implementar FU-016 (diferido en CHARTER-09) como parte del vaciado del backlog antes del publish (T060). Segundo de tres Charters de ese vaciado, tras CHARTER-12. La investigación previa resolvió las dos incógnitas que hacían de FU-016 un coste M: la API real de set_peer_id de loro 1.13.6 y el default de record_timestamp — ambas verificadas en las fuentes pinneadas, lo que baja el coste a S." +originating_spec: specs/001-weft-crdt-versioning/spec.md +work_verb: implement +design_provenance: new +--- + +# Charter: Siembra de réplica cross-engine — IDeterministicSeeding + peer_id de Loro + +> **Status (mirrored from frontmatter — source of truth is above):** in-progress. Effort: S. +> +> **Origin:** FU-016. CHARTER-09 expuso la siembra de client-id como capacidad **concreta de +> `YrsEngine`** (`CreateDoc(ulong)`), no en `ICrdtEngine`, porque el gate `determinism-yjs` es +> yrs↔Yjs. Este Charter la promueve a capacidad cross-engine y añade el equivalente de Loro. + +## Context + +Un `LoroDoc` fresco recibe un `peer_id` **aleatorio** (`loro-internal/src/state.rs`: `DefaultRandom`), +así que hoy Loro **no tiene golden de determinismo**: dos ejecuciones producen exports distintos. yrs +sí lo tiene desde CHARTER-09, vía `weft_doc_new_with_client_id` + el gate de paridad contra Yjs. +Sembrar el `peer_id` de Loro cierra esa asimetría — pero la promoción tiene un crux de diseño y un +espejismo de gate que la investigación resolvió antes de escribir código. + +**El crux (resuelto): capacidad opcional, no método en `ICrdtEngine`.** El argumento «un motor futuro +podría no tener id sembrable» es débil (casi todo CRDT op-based lo tiene). El argumento decisivo es la +**asimetría de dominios**, verificada en las fuentes de las versiones pinneadas: + +| Motor | Dominio válido del id | Fuente | +|---|---|---| +| yrs 0.27.2 | `< 2^53` (encoding de 53 bits) | `weft-yrs-ffi/src/lib.rs:57` | +| loro 1.13.6 | `u64` completo **excepto `u64::MAX`** (reservado → `LoroError::InvalidPeerID`) | `loro-internal/src/loro.rs:184` | + +Un `ICrdtEngine.CreateDoc(ulong)` único **no puede enunciar un contrato uniforme sobre su dominio +válido**: el llamador tendría que ramificar por `engine.Name` para saber si `2^60` es legal — la +abstracción se filtra justo donde P-IV existe para protegerla. La solución es el patrón ya establecido +en el repo (`ICrdtEngine.NativeVersioning → INativeVersioning?`): una interfaz opcional +`IDeterministicSeeding` que hace del dominio parte del contrato (`MaxReplicaIdExclusive`). + +**El espejismo de gate (descartado): «Loro↔referencia» NO es realizable.** FU-016 pedía un gate de +paridad de Loro contra una referencia. No existe una implementación independiente de Loro: +`loro-crdt` de npm es un build wasm del **mismo** core Rust, así que compararlo con nuestro `loro` +nativo es compararlo consigo mismo — tautológico. El gate yrs↔Yjs es significativo porque Yjs y yrs +son implementaciones genuinamente independientes de un formato; Loro no tiene contraparte. El gate +**realizable y útil** es distinto: *Loro es determinista consigo mismo, cross-run y cross-RID, con +`peer_id` fijo* (clase R13(a), no R13(b)). Lo habilitan dos hechos verificados: el `peer_id` aleatorio +por defecto (de ahí que hoy no haya golden) y `record_timestamp = false` por defecto +(`loro-internal/src/configure.rs:33` — si fuera `true`, el gate sería imposible con o sin `peer_id`). + +## Scope + +**In scope:** + +1. **`IDeterministicSeeding`** (nuevo) en `Weft.Core.Abstractions`: `ulong MaxReplicaIdExclusive` + + `ICrdtDoc CreateDoc(ulong replicaId)`. Expuesta como `ICrdtEngine.DeterministicSeeding` (`null` si el + motor no la soporta — hoy ambos la soportan). Nombre `replicaId`, no `clientId`/`peer_id` (dialectos + de motor). +2. **`YrsDeterministicSeeding`** (nuevo): delega en `YrsEngine.CreateDoc(ulong)` ya existente; + `MaxReplicaIdExclusive = 1UL << 53`. El método concreto se conserva (no romper CHARTER-09). +3. **Shim de Loro**: `weft_loro_doc_new_with_peer_id(uint64 peer_id, out doc)` con guard de `u64::MAX` + en la frontera (espejo del guard de rango de yrs), `catch_unwind`. **ABI v2 → v3.** +4. **`LoroDeterministicSeeding`** (nuevo): `MaxReplicaIdExclusive = ulong.MaxValue`; binding + + `LoroDoc.Create(ulong)`; `NativeLibraryResolver.ExpectedAbiVersion = 3`. +5. **Gate de auto-determinismo de Loro**: `[Theory]` en `DeterminismTests` que aplica el corpus + compartido con `peer_id` fijo y asierta contra `golden-loro.json` (nuevo). Documentado como + **testigo de regresión, no prueba de paridad** — caza un cambio de encoding al bumpear `loro` (R16). +6. **Test de paridad header↔binding**: el de CHARTER-12 (`HeaderBindingParityTests`) valida + automáticamente la nueva función y el bump de ABI. **No hay que escribirlo — ya existe, por eso este + Charter va después del 12.** Verificar que sigue verde. + +**Out of scope:** + +- **Cablear la siembra en el relay/broker.** Sembrar `peer_id` es un **footgun en producción**: Loro + advierte que reusar el mismo PeerID entre escritores concurrentes corrompe el documento. La capacidad + es para uso determinista de test/corpus. `DocumentBroker`/`WeftServer` siguen usando `CreateDoc()`. +- **Un gate «Loro↔referencia»** — no realizable (ver Context). Se implementa el de auto-determinismo. +- **FU-010** (durabilidad del relay) → CHARTER-14. **FU-015** — bloqueado por upstream. + +## Files to modify + +| File | Change | +|---|---| +| `src/Weft.Core/Abstractions/IDeterministicSeeding.cs` | New — la capacidad opcional | +| `src/Weft.Core/Abstractions/ICrdtEngine.cs` | `+ IDeterministicSeeding? DeterministicSeeding` | +| `src/Weft.Core/Yrs/YrsEngine.cs` | `DeterministicSeeding => YrsDeterministicSeeding.Instance`; actualizar el XML doc de `CreateDoc(ulong)` (dice «se difiere a un follow-up») | +| `src/Weft.Core/Yrs/YrsDeterministicSeeding.cs` | New — `MaxReplicaIdExclusive = 1UL << 53` | +| `native/weft-loro-ffi/src/lib.rs` | `weft_loro_doc_new_with_peer_id` + guard `u64::MAX` + `WEFT_ABI_VERSION` 2→3 | +| `native/weft-loro-ffi/include/weft_loro_ffi.h` | Declarar la fn nueva; ABI v2→v3 en el comentario | +| `native/weft-loro-ffi/tests/mem_asan.rs` | `assert_eq!(weft_loro_abi_version(), 3)` + loop ASan/LSan sobre la fn nueva | +| `src/Weft.Loro/Interop/NativeMethods.cs` | `weft_loro_doc_new_with_peer_id` | +| `src/Weft.Loro/Interop/NativeLibraryResolver.cs` | `ExpectedAbiVersion = 3` | +| `src/Weft.Loro/LoroDoc.cs` | `internal static LoroDoc Create(ulong peerId)` | +| `src/Weft.Loro/LoroEngine.cs` | `DeterministicSeeding => LoroDeterministicSeeding.Instance` | +| `src/Weft.Loro/LoroDeterministicSeeding.cs` | New — `MaxReplicaIdExclusive = ulong.MaxValue` | +| `tests/Weft.Core.Tests/DocumentBrokerTests.cs` | `TrackingEngine` (impl de `ICrdtEngine` en tests) gana `DeterministicSeeding => null` | +| `tests/Weft.Versioning.Tests/DeterministicSeedingTests.cs` | New — forma de la capacidad + guard de rango en ambos motores | +| `tests/Weft.Determinism.Tests/DeterminismTests.cs` | Gate de auto-determinismo de Loro | +| `tests/Weft.Determinism.Tests/Weft.Determinism.Tests.csproj` | ProjectReference a `Weft.Loro` (el gate de Loro lo necesita) | +| `tests/determinism-yjs/golden-loro.json` | New — golden del corpus sembrado (testigo de regresión) | +| `.straymark/follow-ups-backlog.md` | Cierre de FU-016 con su premisa corregida | +| `.straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-002-charter-13-siembra-cross-engine.md` | New, `risk_level: low` | + +## Verification + +### Local checks + +```bash +cargo build --release --features test-hooks --manifest-path native/Cargo.toml +dotnet build Weft.sln -c Release + +# El bump de ABI: el resolver del binding debe aceptar v3; el test de paridad valida la fn nueva +dotnet test tests/Weft.Versioning.Tests -c Release --filter "FullyQualifiedName~HeaderBindingParity" + +# Suite completa + gate de auto-determinismo de Loro +dotnet test Weft.sln -c Release +cargo test --features test-hooks --manifest-path native/Cargo.toml + +# El golden de Loro debe ser estable cross-run: correr dos veces y comparar +dotnet test tests/Weft.Determinism.Tests -c Release --filter "FullyQualifiedName~Loro" +dotnet test tests/Weft.Determinism.Tests -c Release --filter "FullyQualifiedName~Loro" + +# P-II: la fn nueva bajo ASan/LSan +RUSTFLAGS="-Zsanitizer=address" cargo +nightly test --features test-hooks \ + --target x86_64-unknown-linux-gnu --manifest-path native/Cargo.toml + +straymark validate --include-charters +``` + +## Risks + +- **R1 — El gate «Loro↔referencia» del follow-up no es construible**: certeza alta. Planificar contra + él produciría un deliverable fantasma. + Mitigación: se implementa el gate de **auto-determinismo** (cross-run/cross-RID con peer_id fijo), + documentado explícitamente como testigo de regresión, no paridad. La entrada de FU-016 en el registro + se cierra con esta corrección anotada. +- **R2 — El golden de Loro se lee como «prueba de paridad»**: probabilidad media, severidad media. Un + doc futuro podría citarlo como equivalente al gate yrs↔Yjs, que es cualitativamente más fuerte. + Mitigación: el `_comment` de `golden-loro.json` y el doc-comment del test dicen literalmente «testigo + de regresión, NO paridad» y citan la razón (no hay implementación Loro independiente). Exagerar el + alcance de una verificación es la clase que CHARTER-12 acaba de cerrar. +- **R3 — `record_timestamp` cambia de default en un loro futuro** → el auto-determinismo muere en + silencio: probabilidad baja, severidad alta. Hoy es `false` (verificado); si pasara a `true`, el + oplog llevaría wall-clock y el golden divergiría cross-run. + Mitigación: (a) el gate lo caza al bumpear (el golden cambiaría), que es el R16 funcionando; (b) + evaluar asertar el config explícitamente en el shim en vez de confiar en el default — decisión a + tomar al implementar, documentada en el AILOG. +- **R4 — Sembrar `peer_id` es un footgun si se cablea en producción**: probabilidad baja (out of scope), + severidad alta. Reusar un PeerID entre escritores concurrentes corrompe el documento (aviso de Loro). + Mitigación: el XML doc de `IDeterministicSeeding` lo dice; el relay/broker **no** se tocan; ningún + test lo cablea en un escenario multi-cliente. +- **R5 — ABI v3 vs. un `.so` cacheado v2** hace fallar los tests locales por el resolver: probabilidad + media, severidad baja. + Mitigación: el `NativeLibraryResolver` rechaza con excepción clara (es su trabajo); el flujo de + verificación recompila el nativo primero. Se anota en el AILOG. +- **R6 — Promover a `ICrdtEngine.DeterministicSeeding` tensa P-IV** si se hace mal: probabilidad baja + con el patrón de capacidad opcional. + Mitigación: se replica exactamente `NativeVersioning → INativeVersioning?` (null si no se soporta), + que es el patrón bendecido del repo para capacidades de motor. `LoroNativeVersioningTests` ya asierta + `Assert.Null(YrsEngine.Instance.NativeVersioning)`; el test análogo asierta la forma de la nueva. + +## Tasks + +1. Sync main (con CHARTER-12), branch `charter/14-siembra-cross-engine` (número de Charter 13). +2. Shim de Loro: `weft_loro_doc_new_with_peer_id` + guard + ABI v3 + header + `mem_asan.rs`. +3. `IDeterministicSeeding` + `ICrdtEngine` + `YrsDeterministicSeeding` + `LoroDeterministicSeeding` + + binding + `LoroDoc.Create(ulong)` + resolver v3. +4. Tests de la capacidad + guard de rango + gate de auto-determinismo de Loro (`golden-loro.json`). +5. Verificar que `HeaderBindingParityTests` valida la fn nueva sin tocarlo (es el pago del orden 12→13). +6. AILOG (`risk_level: low`) + cerrar FU-016 + `recount`. +7. Verificación local limpia (incl. golden estable en 2 corridas) + `charter drift`. +8. Commit + push + PR. + +## Charter Closure + +1. **Atomic update (format v4)** si el drift reporta deriva no capturada. +2. **Post-merge drift check** `--range origin/main..HEAD`. +3. **Status** `in-progress` → `closed` + `closed_at`. +4. Al cerrar, `straymark followups status` debe bajar a 2 open (FU-010, FU-015). +5. **No borrar** este archivo. diff --git a/.straymark/follow-ups-backlog.md b/.straymark/follow-ups-backlog.md index 452473a..0f64996 100644 --- a/.straymark/follow-ups-backlog.md +++ b/.straymark/follow-ups-backlog.md @@ -1,9 +1,9 @@ --- last_scan: 2026-07-15 schema_version: v1 -total_open: 3 +total_open: 2 total_promoted: 0 -total_closed_in_session: 17 +total_closed_in_session: 18 total_phase_blocked: 0 total_suspected_closed: 0 buckets: @@ -136,11 +136,11 @@ fully_extracted_ailogs: ### FU-016 — promover la siembra de client-id a capacidad cross-engine (Loro peer_id) - **Origin**: AILOG-2026-07-15-001 §Follow-ups · CHARTER-09 (alcance yrs-only) - **Source-hash**: 346b9c62a979 -- **Status**: open +- **Status**: closed - **Trigger**: when se requiera paridad determinista para el motor Loro (gate Loro↔referencia) - **Destination**: mini-charter - **Cost**: M -- **Notes**: CHARTER-09 expuso la siembra de client-id como capacidad **concreta de `YrsEngine`** (`CreateDoc(ulong)`), no en `ICrdtEngine`, porque el gate `determinism-yjs` es yrs↔Yjs (misma familia de formato). Para un gate de determinismo de Loro: promover a capacidad cross-engine — `CreateDoc(clientId)` en `ICrdtEngine` (o una interfaz opcional tipo `INativeVersioning`) + `weft_loro_doc_new_with_peer_id` en `weft-loro-ffi` (Loro vía `set_peer_id`). Ningún gate depende hoy. +- **Notes**: CHARTER-09 expuso la siembra de client-id como capacidad **concreta de `YrsEngine`** (`CreateDoc(ulong)`), no en `ICrdtEngine`, porque el gate `determinism-yjs` es yrs↔Yjs (misma familia de formato). Para un gate de determinismo de Loro: promover a capacidad cross-engine — `CreateDoc(clientId)` en `ICrdtEngine` (o una interfaz opcional tipo `INativeVersioning`) + `weft_loro_doc_new_with_peer_id` en `weft-loro-ffi` (Loro vía `set_peer_id`). Ningún gate depende hoy. **CERRADO 2026-07-16 (CHARTER-13, AILOG-2026-07-16-002)**: dos correcciones de premisa. (1) NO se puso `CreateDoc(ulong)` en `ICrdtEngine` sino una **capacidad opcional** `IDeterministicSeeding` (`ICrdtEngine.DeterministicSeeding`), por la asimetría de dominios (yrs `<2^53`, Loro todo `u64` salvo `MAX`) que un método único no puede contratar sin filtrar P-IV. (2) El «gate Loro↔referencia» **no es construible** (loro-crdt npm = wasm del mismo core Rust); se implementó un gate de **auto-determinismo** (cross-run/cross-RID con peer_id fijo, testigo de regresión). Shim `weft_loro_doc_new_with_peer_id` (ABI v2→v3); `golden-loro.json`; 151/151 tests, ASan sin fugas. ### FU-015 — adopción del fix de R6 vía bump de yrs (protocolo R16) - **Origin**: AILOG-2026-07-14-002 §Follow-ups · CHARTER-08 (d) · PR upstream y-crdt/y-crdt#639 diff --git a/native/weft-loro-ffi/include/weft_loro_ffi.h b/native/weft-loro-ffi/include/weft_loro_ffi.h index 577a115..3301063 100644 --- a/native/weft-loro-ffi/include/weft_loro_ffi.h +++ b/native/weft-loro-ffi/include/weft_loro_ffi.h @@ -49,6 +49,10 @@ typedef struct WeftLoroDoc WeftLoroDoc; /* ── Ciclo de vida del documento ──────────────────────────────────────────────────────── */ int32_t weft_loro_doc_new(WeftLoroDoc** out_doc); +/* Doc nuevo con peer_id FIJO (siembra determinista, FU-016). peer_id == u64::MAX está reservado por + * Loro -> WEFT_ERR_OUT_OF_BOUNDS. Para uso determinista de test/corpus (reusar un peer_id entre + * escritores concurrentes corrompe el doc). ABI v3. */ +int32_t weft_loro_doc_new_with_peer_id(uint64_t peer_id, WeftLoroDoc** out_doc); int32_t weft_loro_doc_load(const uint8_t* blob, size_t blob_len, WeftLoroDoc** out_doc); void weft_loro_doc_free(WeftLoroDoc* doc); @@ -80,7 +84,8 @@ int32_t weft_loro_native_branch_merge_probe(WeftLoroDoc* doc, const uint8_t* fie void weft_loro_buf_free(uint8_t* ptr, size_t len); /* ── Diagnóstico ────────────────────────────────────────────────────────────────────────── - * Versión de ESTA ABI. Hoy: v2 (v1→v2 añadió los tres probes de versionado nativo, CHARTER-10). + * Versión de ESTA ABI. Hoy: v3 (v2→v3 añadió weft_loro_doc_new_with_peer_id, CHARTER-13/FU-016; + * v1→v2 añadió los tres probes de versionado nativo, CHARTER-10). * El resolver del binding la verifica al cargar y rechaza un shim con versión distinta. */ uint32_t weft_loro_abi_version(void); diff --git a/native/weft-loro-ffi/src/lib.rs b/native/weft-loro-ffi/src/lib.rs index 094b766..fd41e9c 100644 --- a/native/weft-loro-ffi/src/lib.rs +++ b/native/weft-loro-ffi/src/lib.rs @@ -19,7 +19,11 @@ pub const WEFT_ERR_UTF8: i32 = -4; pub const WEFT_ERR_OUT_OF_BOUNDS: i32 = -5; pub const WEFT_ERR_PANIC: i32 = -127; -const WEFT_ABI_VERSION: u32 = 2; +const WEFT_ABI_VERSION: u32 = 3; + +/// PeerID reservado por Loro (`loro-internal/src/loro.rs`: `set_peer_id(u64::MAX)` → +/// `LoroError::InvalidPeerID`). Se rechaza en la frontera para no depender del error interno. +const PEER_ID_RESERVED: u64 = u64::MAX; fn guard i32>(f: F) -> i32 { match catch_unwind(AssertUnwindSafe(f)) { @@ -91,6 +95,38 @@ pub unsafe extern "C" fn weft_loro_doc_new(out_doc: *mut *mut LoroDoc) -> i32 { }) } +/// Doc nuevo con `peer_id` FIJO (siembra determinista, FU-016; capacidad `IDeterministicSeeding`). +/// Habilita exports reproducibles cross-run/cross-RID (un `LoroDoc` normal recibe un `peer_id` +/// aleatorio). `peer_id == u64::MAX` está reservado por Loro → `WEFT_ERR_OUT_OF_BOUNDS`. ABI v3. +/// +/// AVISO: reusar un `peer_id` entre escritores concurrentes corrompe el documento (Loro). Esta +/// función es para uso determinista de test/corpus, no para identidad por usuario/dispositivo. +/// +/// # Safety +/// `out_doc` debe ser un puntero escribible no nulo. +#[no_mangle] +pub unsafe extern "C" fn weft_loro_doc_new_with_peer_id( + peer_id: u64, + out_doc: *mut *mut LoroDoc, +) -> i32 { + guard(|| { + if out_doc.is_null() { + return WEFT_ERR_NULL_ARG; + } + if peer_id == PEER_ID_RESERVED { + return WEFT_ERR_OUT_OF_BOUNDS; + } + let doc = LoroDoc::new(); + if doc.set_peer_id(peer_id).is_err() { + // No debería ocurrir: el único fallo documentado de set_peer_id es el valor reservado, + // ya filtrado arriba. Se mapea defensivamente en vez de entrar en pánico. + return WEFT_ERR_APPLY; + } + *out_doc = Box::into_raw(Box::new(doc)); + WEFT_OK + }) +} + /// # Safety /// `blob` debe apuntar a `blob_len` bytes válidos; `out_doc` escribible no nulo. #[no_mangle] diff --git a/native/weft-loro-ffi/tests/mem_asan.rs b/native/weft-loro-ffi/tests/mem_asan.rs index 430afc4..9a5ba4b 100644 --- a/native/weft-loro-ffi/tests/mem_asan.rs +++ b/native/weft-loro-ffi/tests/mem_asan.rs @@ -174,7 +174,38 @@ fn stress_all_functions_2000_iterations() { weft_loro_doc_free(reloaded); weft_loro_doc_free(doc); } - assert_eq!(weft_loro_abi_version(), 2); // ABI v2: + probes de versionado nativo (CHARTER-10) + assert_eq!(weft_loro_abi_version(), 3); // ABI v3: + weft_loro_doc_new_with_peer_id (CHARTER-13) + } +} + +/// Siembra de peer_id (FU-016): reachability, guard del valor reservado y sin fugas (ASan/LSan). +#[test] +fn seeded_peer_id_reachable_guarded_and_nonleaking() { + unsafe { + // Camino feliz: peer_id fijo → doc válido, editable, liberable sin fugas. + let mut doc: *mut LoroDoc = ptr::null_mut(); + assert_eq!(weft_loro_doc_new_with_peer_id(42, &mut doc), WEFT_OK); + assert!(!doc.is_null()); + let field = b"body"; + assert_eq!( + weft_loro_text_insert(doc, field.as_ptr(), field.len(), 0, b"hola".as_ptr(), 4), + WEFT_OK + ); + weft_loro_doc_free(doc); + + // Valor reservado (u64::MAX) → OUT_OF_BOUNDS, sin asignar doc (out queda intacto). + let mut reserved: *mut LoroDoc = ptr::null_mut(); + assert_eq!( + weft_loro_doc_new_with_peer_id(u64::MAX, &mut reserved), + WEFT_ERR_OUT_OF_BOUNDS + ); + assert!(reserved.is_null()); + + // out_doc nulo → NULL_ARG. + assert_eq!( + weft_loro_doc_new_with_peer_id(1, ptr::null_mut()), + WEFT_ERR_NULL_ARG + ); } } diff --git a/src/Weft.Core/Abstractions/ICrdtEngine.cs b/src/Weft.Core/Abstractions/ICrdtEngine.cs index 3f6e1b3..49597c9 100644 --- a/src/Weft.Core/Abstractions/ICrdtEngine.cs +++ b/src/Weft.Core/Abstractions/ICrdtEngine.cs @@ -16,4 +16,10 @@ public interface ICrdtEngine /// Capacidad opcional de versionado nativo; null si el motor no la ofrece. INativeVersioning? NativeVersioning { get; } + + /// + /// Capacidad opcional para sembrar la identidad de réplica (determinismo de test/corpus); + /// null si el motor no la ofrece. + /// + IDeterministicSeeding? DeterministicSeeding { get; } } diff --git a/src/Weft.Core/Abstractions/IDeterministicSeeding.cs b/src/Weft.Core/Abstractions/IDeterministicSeeding.cs new file mode 100644 index 0000000..2e139bf --- /dev/null +++ b/src/Weft.Core/Abstractions/IDeterministicSeeding.cs @@ -0,0 +1,37 @@ +namespace Weft; + +/// +/// Capacidad opcional para sembrar la identidad de réplica de un documento nuevo (el client_id +/// de yrs, el peer_id de Loro), habilitando exports reproducibles cross-run y cross-RID. +/// +/// +/// +/// Se expone como capacidad opcional —no como un método de — por la +/// asimetría del dominio válido entre motores: yrs acepta ids < 2^53 (encoding de 53 +/// bits), Loro acepta todo ulong salvo ulong.MaxValue (reservado). Un método único no +/// podría enunciar un contrato uniforme sobre ese dominio, forzando al llamador a ramificar por motor +/// — la fuga que la constitución P-IV existe para evitar. hace del +/// dominio parte del contrato. Es el mismo patrón que . +/// +/// +/// Uso previsto: determinismo de test/corpus, NO identidad de producción. Reusar la misma +/// identidad de réplica entre escritores concurrentes rompe la garantía CRDT (Loro lo documenta como +/// corrupción del documento). El relay y el broker crean documentos con +/// , que asigna una identidad aleatoria; no siembran. +/// +/// +public interface IDeterministicSeeding +{ + /// + /// Cota superior EXCLUSIVA de la identidad de réplica válida para este motor. yrs: 1UL << + /// 53. Loro: (todo ulong salvo el valor reservado). + /// + ulong MaxReplicaIdExclusive { get; } + + /// + /// Crea un documento vacío con la identidad de réplica fija. + /// + /// Identidad de réplica; debe ser < . + /// fuera del dominio válido. + ICrdtDoc CreateDoc(ulong replicaId); +} diff --git a/src/Weft.Core/Yrs/YrsDeterministicSeeding.cs b/src/Weft.Core/Yrs/YrsDeterministicSeeding.cs new file mode 100644 index 0000000..574cb17 --- /dev/null +++ b/src/Weft.Core/Yrs/YrsDeterministicSeeding.cs @@ -0,0 +1,18 @@ +namespace Weft.Yrs; + +/// +/// Siembra determinista para : fija el client_id del documento. El +/// dominio válido es < 2^53 (encoding de client-ids de 53 bits de yrs 0.26+). +/// +internal sealed class YrsDeterministicSeeding : IDeterministicSeeding +{ + internal static YrsDeterministicSeeding Instance { get; } = new(); + + private YrsDeterministicSeeding() { } + + /// + public ulong MaxReplicaIdExclusive => 1UL << 53; + + /// + public ICrdtDoc CreateDoc(ulong replicaId) => YrsEngine.Instance.CreateDoc(replicaId); +} diff --git a/src/Weft.Core/Yrs/YrsEngine.cs b/src/Weft.Core/Yrs/YrsEngine.cs index bb67e1c..355988d 100644 --- a/src/Weft.Core/Yrs/YrsEngine.cs +++ b/src/Weft.Core/Yrs/YrsEngine.cs @@ -21,15 +21,18 @@ private YrsEngine() { } /// public INativeVersioning? NativeVersioning => null; + /// + public IDeterministicSeeding? DeterministicSeeding => YrsDeterministicSeeding.Instance; + /// public ICrdtDoc CreateDoc() => YrsDoc.Create(); /// - /// Crea un documento con un FIJO. Capacidad yrs-específica - /// (no parte de ): habilita la paridad byte-idéntica cross-implementación - /// con Yjs (gate determinism-yjs, FU-012), que exige client-ids deterministas. El id debe - /// caber en 53 bits (encoding de yrs 0.26+). La promoción a capacidad cross-engine (Loro vía - /// set_peer_id) se difiere a un follow-up. + /// Crea un documento con un FIJO. Habilita la paridad byte-idéntica + /// cross-implementación con Yjs (gate determinism-yjs, FU-012), que exige client-ids + /// deterministas. El id debe caber en 53 bits (encoding de yrs 0.26+). Este método concreto se + /// conserva; la capacidad cross-engine equivalente vive en + /// (CHARTER-13/FU-016), que Loro también implementa vía set_peer_id. /// public ICrdtDoc CreateDoc(ulong clientId) => YrsDoc.Create(clientId); diff --git a/src/Weft.Loro/Interop/NativeLibraryResolver.cs b/src/Weft.Loro/Interop/NativeLibraryResolver.cs index 8ae18e0..59c7c45 100644 --- a/src/Weft.Loro/Interop/NativeLibraryResolver.cs +++ b/src/Weft.Loro/Interop/NativeLibraryResolver.cs @@ -8,7 +8,7 @@ namespace Weft.Loro.Interop; internal static class NativeLibraryResolver { // ABI v2 (CHARTER-10): + probes de versionado nativo (INativeVersioning, FU-006). - private const uint ExpectedAbiVersion = 2; + private const uint ExpectedAbiVersion = 3; private static int _registered; [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/Weft.Loro/Interop/NativeMethods.cs b/src/Weft.Loro/Interop/NativeMethods.cs index b024d0b..e8e0ce4 100644 --- a/src/Weft.Loro/Interop/NativeMethods.cs +++ b/src/Weft.Loro/Interop/NativeMethods.cs @@ -10,6 +10,9 @@ internal static partial class NativeMethods [LibraryImport(Lib)] internal static partial int weft_loro_doc_new(out nint outDoc); + [LibraryImport(Lib)] + internal static partial int weft_loro_doc_new_with_peer_id(ulong peerId, out nint outDoc); + [LibraryImport(Lib)] internal static partial int weft_loro_doc_load(ReadOnlySpan blob, nuint blobLen, out nint outDoc); diff --git a/src/Weft.Loro/LoroDeterministicSeeding.cs b/src/Weft.Loro/LoroDeterministicSeeding.cs new file mode 100644 index 0000000..74ab29f --- /dev/null +++ b/src/Weft.Loro/LoroDeterministicSeeding.cs @@ -0,0 +1,18 @@ +namespace Weft.Loro; + +/// +/// Siembra determinista para : fija el peer_id del documento. El +/// dominio válido es todo ulong salvo (reservado por Loro). +/// +internal sealed class LoroDeterministicSeeding : IDeterministicSeeding +{ + internal static LoroDeterministicSeeding Instance { get; } = new(); + + private LoroDeterministicSeeding() { } + + /// + public ulong MaxReplicaIdExclusive => ulong.MaxValue; + + /// + public ICrdtDoc CreateDoc(ulong replicaId) => LoroDoc.Create(replicaId); +} diff --git a/src/Weft.Loro/LoroDoc.cs b/src/Weft.Loro/LoroDoc.cs index ccccfb9..9e0dd73 100644 --- a/src/Weft.Loro/LoroDoc.cs +++ b/src/Weft.Loro/LoroDoc.cs @@ -19,6 +19,12 @@ internal static LoroDoc Create() return new LoroDoc(new DocHandle(raw)); } + internal static LoroDoc Create(ulong peerId) + { + FfiStatus.ThrowIfError(NativeMethods.weft_loro_doc_new_with_peer_id(peerId, out nint raw)); + return new LoroDoc(new DocHandle(raw)); + } + internal static LoroDoc Load(ReadOnlySpan blob) { FfiStatus.ThrowIfError(NativeMethods.weft_loro_doc_load(blob, (nuint)blob.Length, out nint raw)); diff --git a/src/Weft.Loro/LoroEngine.cs b/src/Weft.Loro/LoroEngine.cs index ae93643..9794efa 100644 --- a/src/Weft.Loro/LoroEngine.cs +++ b/src/Weft.Loro/LoroEngine.cs @@ -27,6 +27,9 @@ private LoroEngine() { } /// public INativeVersioning? NativeVersioning => LoroNativeVersioning.Instance; + /// + public IDeterministicSeeding? DeterministicSeeding => LoroDeterministicSeeding.Instance; + /// public ICrdtDoc CreateDoc() => LoroDoc.Create(); diff --git a/tests/Weft.Core.Tests/DocumentBrokerTests.cs b/tests/Weft.Core.Tests/DocumentBrokerTests.cs index 120eb36..8ece6fb 100644 --- a/tests/Weft.Core.Tests/DocumentBrokerTests.cs +++ b/tests/Weft.Core.Tests/DocumentBrokerTests.cs @@ -245,6 +245,7 @@ private sealed class TrackingEngine : ICrdtEngine public List Docs { get; } = []; public string Name => "tracking"; public INativeVersioning? NativeVersioning => null; + public IDeterministicSeeding? DeterministicSeeding => null; public ICrdtDoc CreateDoc() { diff --git a/tests/Weft.Determinism.Tests/DeterminismTests.cs b/tests/Weft.Determinism.Tests/DeterminismTests.cs index c73faf8..90a84ef 100644 --- a/tests/Weft.Determinism.Tests/DeterminismTests.cs +++ b/tests/Weft.Determinism.Tests/DeterminismTests.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text.Json; using Weft; +using Weft.Loro; using Weft.Versioning; using Weft.Versioning.Blobs; using Weft.Yrs; @@ -162,6 +163,91 @@ public void Yrs_export_matches_yjs_golden(string corpusFile, string goldenKey) } } + // ── Auto-determinismo de Loro con peer_id sembrado (FU-016/CHARTER-13, research R13(a), P-III) ── + // A diferencia del gate de yrs (paridad vs Yjs, una implementación INDEPENDIENTE), Loro NO tiene + // contraparte independiente: loro-crdt de npm es un build wasm del MISMO core Rust, así que un + // "gate Loro↔referencia" sería tautológico. Lo que este gate fija es más modesto y honesto: con un + // peer_id sembrado, el export de Loro sobre el corpus compartido es ESTABLE cross-run y cross-RID. + // El golden es un TESTIGO DE REGRESIÓN, no una prueba de paridad: su valor es cazar un cambio de + // encoding al bumpear `loro` (R16). Lo habilita que record_timestamp sea false por defecto en loro + // 1.13.6 (verificado); si eso cambiara, este gate lo detectaría al regenerar el golden. + [Theory] + [InlineData("corpus.json", "ascii")] + [InlineData("corpus-unicode.json", "unicode")] + public void Loro_seeded_export_matches_golden(string corpusFile, string goldenKey) + { + string dir = DeterminismCorpusDir(); + CorpusSpec corpus = LoadCorpus(Path.Combine(dir, corpusFile)); + string golden = GoldenHash(Path.Combine(dir, "golden-loro.json"), goldenKey); + + string hash = LoroSeededExportHash(corpus); + Assert.Equal(golden, hash); + } + + [Theory] + [InlineData("corpus.json")] + [InlineData("corpus-unicode.json")] + public void Loro_seeded_export_is_stable_across_runs(string corpusFile) + { + // El auto-determinismo es la premisa del golden: sin él, fijar un hash no tendría sentido. + CorpusSpec corpus = LoadCorpus(Path.Combine(DeterminismCorpusDir(), corpusFile)); + Assert.Equal(LoroSeededExportHash(corpus), LoroSeededExportHash(corpus)); + } + + // Aplica el corpus compartido a réplicas de Loro sembradas con los ClientIds como peer_ids (los + // ClientIds del corpus son int pequeños, válidos para ambos motores), sincroniza a convergencia y + // devuelve el SHA-256 del export de la réplica 0 — mismo esquema que el gate de yrs. + private static string LoroSeededExportHash(CorpusSpec corpus) + { + IDeterministicSeeding seeding = LoroEngine.Instance.DeterministicSeeding + ?? throw new InvalidOperationException("LoroEngine.DeterministicSeeding no debe ser null (FU-016)."); + + ICrdtDoc[] replicas = [.. corpus.ClientIds.Select(id => seeding.CreateDoc((ulong)id))]; + try + { + foreach (CorpusOp step in corpus.Ops) + { + ICrdtDoc doc = replicas[step.Replica]; + if (step.Op == "ins") + { + doc.InsertText(corpus.Type, step.Index, step.Text!); + } + else if (step.Op == "del") + { + doc.DeleteText(corpus.Type, step.Index, step.Len); + } + else + { + throw new InvalidOperationException($"op desconocida: {step.Op}"); + } + } + + for (int pass = 0; pass < corpus.SyncPasses; pass++) + { + foreach (ICrdtDoc target in replicas) + { + byte[] sv = target.ExportStateVector(); + foreach (ICrdtDoc source in replicas) + { + if (!ReferenceEquals(source, target)) + { + target.ApplyUpdate(source.ExportUpdateSince(sv)); + } + } + } + } + + return Convert.ToHexStringLower(SHA256.HashData(replicas[0].ExportState())); + } + finally + { + foreach (ICrdtDoc r in replicas) + { + r.Dispose(); + } + } + } + // Localiza tests/determinism-yjs/ subiendo desde el binario del test hasta la raíz del repo. private static string DeterminismCorpusDir() { diff --git a/tests/Weft.Determinism.Tests/Weft.Determinism.Tests.csproj b/tests/Weft.Determinism.Tests/Weft.Determinism.Tests.csproj index 371e396..fde2d7f 100644 --- a/tests/Weft.Determinism.Tests/Weft.Determinism.Tests.csproj +++ b/tests/Weft.Determinism.Tests/Weft.Determinism.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/tests/Weft.Versioning.Tests/DeterministicSeedingTests.cs b/tests/Weft.Versioning.Tests/DeterministicSeedingTests.cs new file mode 100644 index 0000000..d725754 --- /dev/null +++ b/tests/Weft.Versioning.Tests/DeterministicSeedingTests.cs @@ -0,0 +1,87 @@ +using Weft; +using Weft.Loro; +using Weft.Yrs; + +namespace Weft.Versioning.Tests; + +/// +/// Capacidad opcional (CHARTER-13/FU-016): sembrar la identidad de +/// réplica (client_id de yrs, peer_id de Loro) para exports reproducibles. Estos tests fijan la +/// asimetría del dominio válido entre motores — la razón por la que la capacidad es una interfaz +/// opcional y no un método de . +/// +public sealed class DeterministicSeedingTests +{ + public static TheoryData Engines() => new() { YrsEngine.Instance, LoroEngine.Instance }; + + [Theory] + [MemberData(nameof(Engines))] + public void Both_engines_expose_deterministic_seeding(ICrdtEngine engine) + { + Assert.NotNull(engine.DeterministicSeeding); + } + + [Fact] + public void Yrs_replica_id_domain_is_53_bits() + { + Assert.Equal(1UL << 53, YrsEngine.Instance.DeterministicSeeding!.MaxReplicaIdExclusive); + } + + [Fact] + public void Loro_replica_id_domain_is_full_ulong_minus_reserved() + { + // El u64::MAX reservado por Loro es el único valor fuera del dominio; la cota exclusiva es MaxValue. + Assert.Equal(ulong.MaxValue, LoroEngine.Instance.DeterministicSeeding!.MaxReplicaIdExclusive); + } + + [Fact] + public void Loro_reserved_peer_id_throws_out_of_range() + { + // u64::MAX está reservado por Loro; el shim lo rechaza en la frontera → ArgumentOutOfRangeException. + Assert.Throws( + () => LoroEngine.Instance.DeterministicSeeding!.CreateDoc(ulong.MaxValue)); + } + + [Fact] + public void Yrs_replica_id_beyond_53_bits_throws_out_of_range() + { + // yrs solo acepta < 2^53; el shim rechaza el exceso en la frontera. + Assert.Throws( + () => YrsEngine.Instance.DeterministicSeeding!.CreateDoc(1UL << 53)); + } + + [Theory] + [MemberData(nameof(Engines))] + public void Seeded_doc_converges_with_an_unseeded_peer(ICrdtEngine engine) + { + // Sembrar la identidad no debe romper la semántica CRDT: un doc sembrado y otro normal convergen. + using ICrdtDoc seeded = engine.DeterministicSeeding!.CreateDoc(7); + using ICrdtDoc other = engine.CreateDoc(); + seeded.InsertText("body", 0, "hola"); + + other.ApplyUpdate(seeded.ExportUpdateSince(other.ExportStateVector())); + Assert.Equal("hola", other.GetText("body")); + } + + [Fact] + public void Loro_seeded_export_is_stable_across_two_docs_with_the_same_peer_id() + { + // El valor de la siembra: dos docs con el MISMO peer_id y las MISMAS ediciones exportan igual. + // Sin siembra, el peer_id aleatorio de Loro haría divergir estos bytes. (yrs ya tiene su gate + // de paridad vs Yjs; aquí se fija el equivalente de Loro a nivel de unidad.) + byte[] a = SeededExport(LoroEngine.Instance, peerId: 99); + byte[] b = SeededExport(LoroEngine.Instance, peerId: 99); + Assert.Equal(a, b); + + // Y un peer_id distinto produce bytes distintos (la siembra realmente influye en el encoding). + byte[] c = SeededExport(LoroEngine.Instance, peerId: 100); + Assert.NotEqual(a, c); + } + + private static byte[] SeededExport(ICrdtEngine engine, ulong peerId) + { + using ICrdtDoc doc = engine.DeterministicSeeding!.CreateDoc(peerId); + doc.InsertText("body", 0, "contenido determinista"); + return doc.ExportState(); + } +} diff --git a/tests/determinism-yjs/golden-loro.json b/tests/determinism-yjs/golden-loro.json new file mode 100644 index 0000000..c5a6026 --- /dev/null +++ b/tests/determinism-yjs/golden-loro.json @@ -0,0 +1,5 @@ +{ + "_comment": "Hashes golden de LORO (SHA-256 del export de la réplica 0 convergida) para el corpus compartido, con peer_id sembrado (= ClientIds del corpus). TESTIGO DE REGRESIÓN, NO prueba de paridad: a diferencia del golden de Yjs (golden.json), Loro no tiene una implementación independiente contra la que comparar (loro-crdt de npm es wasm del mismo core Rust), así que esto solo fija que el encoding de Loro es estable cross-run/cross-RID. Su valor es cazar un cambio de encoding al bumpear `loro` (R16). Weft.Determinism.Tests.Loro_seeded_export_matches_golden lo asierta (CHARTER-13/FU-016). Regenerar si el bump de loro es intencional; un cambio inesperado es una regresión a investigar.", + "ascii": "33ecf0ee4a9e3d3a24445e41b87c37f219029c9b2937901f8188e93394f7d515", + "unicode": "0d8e511272675a9423df37b435736afeeff3fb83ef15c6e4a6ff910668d1f819" +}