namespace Mibo.Adaptive open System open System.Collections.Generic // ============================================================================= // Per-element adaptive map node (docs/2026-08-05-MAPA-DESIGN.md) // // AMap.mapA / chooseA / filterA share one node, the keyed sibling of // ElementSetNode: the mapping returns an aval per entry, cached by key. See // ElementSetNode.fs for the dirty gate and scan contracts; this node differs // only in the journal shape (map deltas) and the output state (a plain keyed // dictionary). // ============================================================================= /// /// Maps every entry of a map to an adaptive value (or chooses/filters, when /// the aval's value is ValueNone to drop the entry). /// type ElementMapNode<'K, 'V, 'U when 'K: equality> ( source: IAdaptiveMap<'K, 'V>, [] mapping: 'K -> 'V -> aval<'U voption> ) = let mutable state = MapNodeState<'K, 'V, 'U>.Create(1) let mutable cache = Dictionary<'K, ElementEntry<'U>>() let mutable initialized = false let mutable disposed = false let mutable elementDirty = false let mutable lastDrainWriteGen = -1L member private this.Register() = match box source with | :? IMapSinkRegistry as r -> r.AddMapSink(box(this :> IMapDeltaSink<'K, 'V>)) | _ -> () member private this.Unregister() = match box source with | :? IMapSinkRegistry as r -> r.RemoveMapSink(box(this :> IMapDeltaSink<'K, 'V>)) | _ -> () /// The dirty gate: a scan is needed when the self-healing flag is set, or /// when a write moved the generation since the last scan. member private _.NeedsElementScan() = elementDirty || GraphContext.Default.WriteGeneration <> lastDrainWriteGen member private this.EnsureInitialized() = if not initialized then // Snapshot first, register between, then load (see ElementSetNode). let snapshot = Dictionary<'K, 'V>(source.GetValue()) this.Register() for KeyValue(k, v) in snapshot do let aval = mapping k v // Version read BEFORE the force: a mid-force write leaves the // stored version stale, so the next scan re-forces. let preV = aval.Version let newV = aval.GetValue() let entry = ElementEntry(aval, preV, newV) match newV with | ValueSome u -> state.Data[k] <- u | ValueNone -> () cache[k] <- entry state.DepVersions[0] <- Collections.committedVersion source initialized <- true /// Apply the source journal with cache maintenance: removed keys drop /// their cache entry (unregistered) and their output; set keys run the /// mapping, force the aval, and replace the output. member private this.DrainJournal() = let rems = state.Journal.Rems let sets = state.Journal.Sets let remStart = rems.Count let setStart = sets.Count let mutable i = 0 // Suspend the append-time cross-kind cancellation while the journal // is replayed (see journalAppendMap). state.Journal.InDrain[0] <- 1 // Consumed counts: entries before these positions were applied and // must never be applied again; the throwing entry (and reentrant // entries appended live, beyond the start bounds) survive. let mutable remsDone = 0 let mutable setsDone = 0 try while i < remStart do let k = rems.Items[i] cache.Remove k |> ignore if state.Data.Remove k then state.Out.Rems <- Collections.bufferAppend state.Out.Rems k i <- i + 1 remsDone <- i i <- 0 while i < setStart do let struct (k, v) = sets.Items[i] let aval = mapping k v // Version read BEFORE the force (see EnsureInitialized). let preV = aval.Version let newV = aval.GetValue() let newEntry = ElementEntry(aval, preV, newV) match newV with | ValueSome u -> let mutable old = Unchecked.defaultof<'U> if state.Data.TryGetValue(k, &old) && EqualityComparer<'U>.Default.Equals(old, u) then () else state.Data[k] <- u state.Out.Sets <- Collections.bufferAppend state.Out.Sets (struct (k, u)) | ValueNone -> if state.Data.Remove k then state.Out.Rems <- Collections.bufferAppend state.Out.Rems k cache[k] <- newEntry i <- i + 1 setsDone <- i finally state.Journal.InDrain[0] <- 0 // Compact against the LIVE journal counts (see ElementSetNode). let remLive = state.Journal.Rems.Count if remLive > remsDone then Array.Copy( state.Journal.Rems.Items, remsDone, state.Journal.Rems.Items, 0, remLive - remsDone ) state.Journal.Rems.Count <- remLive - remsDone else state.Journal.Rems.Count <- 0 let setLive = state.Journal.Sets.Count if setLive > setsDone then Array.Copy( state.Journal.Sets.Items, setsDone, state.Journal.Sets.Items, 0, setLive - setsDone ) state.Journal.Sets.Count <- setLive - setsDone else state.Journal.Sets.Count <- 0 /// The element scan: re-read every cached aval's version; force the /// changed ones and apply the contribution change. Self-healing: a /// reentrant write mid-scan keeps the dirty flag set so the next read /// rescans. member private this.ScanElements() = let startGen = GraphContext.Default.WriteGeneration let mutable changed = false let mutable e = cache.GetEnumerator() while e.MoveNext() do let kvp = e.Current let mutable entry = kvp.Value // Version read BEFORE the force (see EnsureInitialized). let preV = entry.Aval.Version if preV <> entry.Version then let newV = entry.Aval.GetValue() match newV with | ValueSome u -> let mutable old = Unchecked.defaultof<'U> if state.Data.TryGetValue(kvp.Key, &old) && EqualityComparer<'U>.Default.Equals(old, u) then () // version bumped to an equal value: no delta else state.Data[kvp.Key] <- u state.Out.Sets <- Collections.bufferAppend state.Out.Sets (struct (kvp.Key, u)) changed <- true | ValueNone -> if state.Data.Remove kvp.Key then state.Out.Rems <- Collections.bufferAppend state.Out.Rems kvp.Key changed <- true entry.Version <- preV entry.Last <- newV cache[kvp.Key] <- entry if changed then state.Version <- state.Version + 1L if GraphContext.Default.WriteGeneration <> startGen then // A reentrant write landed mid-scan: the output may be stale, keep // the flag so the next read rescans. elementDirty <- true else elementDirty <- false /// Drain the journal, then the element scan, then push the accumulated /// output delta once, with notification delivery deferred. member private this.Process() = let ctx = GraphContext.Default let wasActive = ctx.TxActive ctx.TxActive <- true try if not state.Journal.IsEmpty then this.DrainJournal() if this.NeedsElementScan() then this.ScanElements() if not state.Out.IsEmpty then state.Version <- state.Version + 1L Collections.pushMapDelta &state.Sinks state.Out state.Out.Clear() finally ctx.TxActive <- wasActive // Capture AFTER the push: the downstream sink's write notification // advances the write generation during the delta delivery. Capturing // in the scan (before the push) left the gate permanently open and // the Version dirty indicator inflated, so a version-gated consumer // recorded a phantom version and missed the next change. lastDrainWriteGen <- GraphContext.Default.WriteGeneration interface ICommittedVersion with member this.CommittedVersion = state.Version interface IMapDeltaSink<'K, 'V> with member this.OnDeltas ( setEntries: struct ('K * 'V)[], setCnt: int, removedKeys: 'K[], remCnt: int ) = if not disposed then Collections.journalAppendMap &state.Journal setEntries setCnt removedKeys remCnt state.Version <- state.Version + 1L GraphContext.Default.BumpWriteGeneration() interface IAdaptiveMap<'K, 'U> with member this.GetValue() = let ctx = GraphContext.Default ctx.ClaimOwner() try if disposed then invalidOp "This adaptive map has been disposed." this.EnsureInitialized() if source.Version <> state.DepVersions[0] then source.GetValue() |> ignore state.DepVersions[0] <- Collections.committedVersion source this.Process() AdaptiveRuntime.addDependency (this :> IAdaptiveObject) state.Version state.Data :> IReadOnlyDictionary<'K, 'U> finally ctx.ReleaseOwner() member this.Version = // Dirty indicator for version-checking readers (see ElementSetNode). if this.NeedsElementScan() then state.Version + 1L else state.Version interface IDisposable with member this.Dispose() = if not disposed then disposed <- true this.Unregister() Collections.clearSinks &state.Sinks interface IMapSinkRegistry with member this.AddMapSink(sink) = Collections.addSink &state.Sinks sink member this.RemoveMapSink(sink) = Collections.removeSink &state.Sinks sink // ============================================================================= // The per-key join node (docs/2026-08-10-JOIN-DESIGN.md) // // AMap.joinOn — an equi-join over two maps with a computed join key. The left // map is enumerated per key; the join key is computed from the left entry // (keyOfLeft); the right map is looked up per entry (MapLookupNode: registers // nothing, read-time gate), never enumerated or rebuilt. // // The mapping receives the left value as a SWAPPABLE input: a ChangeableValue // the node re-applies on every update of the entry. The mapping's subgraph is // built once per key and survives updates — an in-place input swap instead of // the rebuild-on-journal of a plain mapA closure (the measured join // projection: ~5% of busy time as AdaptiveNode ZeroCreate, from the // per-update rebuild of every per-key subgraph). // Join-key changes (rare) rebuild the subgraph against the new lookup; the // cell survives the rebuild. // // The right map is never a dependency of the node itself: right-side changes // reach the entries through the lookups (version-gated, re-read at force // time) and through the element scan (gated on the write generation, which // every delivery bumps). // ============================================================================= /// /// Cache entry of . The /// swappable left input (Cell), the current join key, and the /// version/last protocol of the element nodes. /// [] type internal JoinEntry<'K2, 'V1, 'U> = val mutable Aval: aval<'U voption> val mutable Version: int64 val mutable Last: 'U voption val mutable Cell: ChangeableValue<'V1> val mutable JoinKey: 'K2 new ( aval: aval<'U voption>, version: int64, last: 'U voption, cell: ChangeableValue<'V1>, joinKey: 'K2 ) = { Aval = aval Version = version Last = last Cell = cell JoinKey = joinKey } /// /// Per-key equi-join over two adaptive maps (the node behind /// AMap.joinOn). Every left entry maps to an output entry keyed by the /// left key; the join key is computed from the left entry and looked up in the /// right map. The mapping receives the left key, the left value as an /// adaptive value, and the right-side value (or ValueNone), and returns /// the output aval; a ValueNone output drops the entry (choose /// semantics). The per-key subgraph is built once and updated in place: left /// updates re-apply the value cell (no rebuild), join-key changes re-run the /// mapping against the new lookup. /// type JoinMapNode<'K1, 'V1, 'K2, 'V2, 'U when 'K1: equality and 'K2: equality> ( left: IAdaptiveMap<'K1, 'V1>, right: IAdaptiveMap<'K2, 'V2>, [] keyOfLeft: 'K1 -> 'V1 -> 'K2, [] mapping: 'K1 -> aval<'V1> -> aval<'V2 voption> -> aval<'U voption> ) = let mutable state = MapNodeState<'K1, 'V1, 'U>.Create(1) let mutable cache = Dictionary<'K1, JoinEntry<'K2, 'V1, 'U>>() let mutable initialized = false let mutable disposed = false let mutable elementDirty = false let mutable lastDrainWriteGen = -1L member private this.Register() = match box left with | :? IMapSinkRegistry as r -> r.AddMapSink(box(this :> IMapDeltaSink<'K1, 'V1>)) | _ -> () member private this.Unregister() = match box left with | :? IMapSinkRegistry as r -> r.RemoveMapSink(box(this :> IMapDeltaSink<'K1, 'V1>)) | _ -> () /// The dirty gate: a scan is needed when the self-healing flag is set, or /// when a write moved the generation since the last scan. member private _.NeedsElementScan() = elementDirty || GraphContext.Default.WriteGeneration <> lastDrainWriteGen /// Build the per-key subgraph once: the swappable value cell, the right /// lookup at the computed join key, and the mapping over both. The entry /// keeps the cell and the join key; updates re-apply the cell in place. member private this.CreateEntry(k: 'K1, v: 'V1) = let cell = ChangeableValue<'V1>(v) let joinKey = keyOfLeft k v let lookup = new MapLookupNode<'K2, 'V2>(right, joinKey) let aval = mapping k (cell :> aval<'V1>) (lookup :> aval<'V2 voption>) // Version read BEFORE the force (see ElementSetNode). let preV = aval.Version let newV = aval.GetValue() JoinEntry(aval, preV, newV, cell, joinKey) member private this.EnsureInitialized() = if not initialized then // Snapshot first, register between, then load (see ElementSetNode). let snapshot = Dictionary<'K1, 'V1>(left.GetValue()) this.Register() let mutable e = snapshot.GetEnumerator() while e.MoveNext() do let kvp = e.Current let entry = this.CreateEntry(kvp.Key, kvp.Value) match entry.Last with | ValueSome u -> state.Data[kvp.Key] <- u | ValueNone -> () cache[kvp.Key] <- entry state.DepVersions[0] <- Collections.committedVersion left initialized <- true /// Apply the source journal with cache maintenance: removed keys drop /// their cache entry (and the right lookup dies with it); set keys swap /// the entry's value cell in place (no rebuild), re-running the mapping /// only when the computed join key changed. member private this.DrainJournal() = let rems = state.Journal.Rems let sets = state.Journal.Sets let remStart = rems.Count let setStart = sets.Count let mutable i = 0 // Suspend the append-time cross-kind cancellation while the journal // is replayed (see journalAppendMap). state.Journal.InDrain[0] <- 1 let mutable remsDone = 0 let mutable setsDone = 0 try while i < remStart do let k = rems.Items[i] cache.Remove k |> ignore if state.Data.Remove k then state.Out.Rems <- Collections.bufferAppend state.Out.Rems k i <- i + 1 remsDone <- i i <- 0 while i < setStart do let struct (k, v) = sets.Items[i] let mutable entry = Unchecked.defaultof> if cache.TryGetValue(k, &entry) then // In-place swap: re-apply the cell (equality-gated, marks // the subgraph dirty), then re-point the lookup when the // join key moved (rare: a target switch). entry.Cell.Apply(v) let joinKey = keyOfLeft k v if joinKey <> entry.JoinKey then let lookup = new MapLookupNode<'K2, 'V2>(right, joinKey) let aval = mapping k (entry.Cell :> aval<'V1>) (lookup :> aval<'V2 voption>) entry.Aval <- aval entry.JoinKey <- joinKey // Version read BEFORE the force (see EnsureInitialized). let preV = entry.Aval.Version let newV = entry.Aval.GetValue() match newV with | ValueSome u -> let mutable old = Unchecked.defaultof<'U> if state.Data.TryGetValue(k, &old) && EqualityComparer<'U>.Default.Equals(old, u) then () else state.Data[k] <- u state.Out.Sets <- Collections.bufferAppend state.Out.Sets (struct (k, u)) | ValueNone -> if state.Data.Remove k then state.Out.Rems <- Collections.bufferAppend state.Out.Rems k entry.Version <- preV entry.Last <- newV cache[k] <- entry else let entry = this.CreateEntry(k, v) match entry.Last with | ValueSome u -> let mutable old = Unchecked.defaultof<'U> if state.Data.TryGetValue(k, &old) && EqualityComparer<'U>.Default.Equals(old, u) then () else state.Data[k] <- u state.Out.Sets <- Collections.bufferAppend state.Out.Sets (struct (k, u)) | ValueNone -> if state.Data.Remove k then state.Out.Rems <- Collections.bufferAppend state.Out.Rems k cache[k] <- entry i <- i + 1 setsDone <- i finally state.Journal.InDrain[0] <- 0 // Compact against the LIVE journal counts (see ElementSetNode). let remLive = state.Journal.Rems.Count if remLive > remsDone then Array.Copy( state.Journal.Rems.Items, remsDone, state.Journal.Rems.Items, 0, remLive - remsDone ) state.Journal.Rems.Count <- remLive - remsDone else state.Journal.Rems.Count <- 0 let setLive = state.Journal.Sets.Count if setLive > setsDone then Array.Copy( state.Journal.Sets.Items, setsDone, state.Journal.Sets.Items, 0, setLive - setsDone ) state.Journal.Sets.Count <- setLive - setsDone else state.Journal.Sets.Count <- 0 /// The element scan: re-read every cached aval's version; force the /// changed ones and apply the contribution change. Self-healing: a /// reentrant write mid-scan keeps the dirty flag set so the next read /// rescans. Right-map changes reach the entries here (the lookup version /// moves; the entry aval reports it; the write generation gate opens). member private this.ScanElements() = let startGen = GraphContext.Default.WriteGeneration let mutable changed = false let mutable e = cache.GetEnumerator() while e.MoveNext() do let kvp = e.Current let mutable entry = kvp.Value // Version read BEFORE the force (see EnsureInitialized). let preV = entry.Aval.Version if preV <> entry.Version then let newV = entry.Aval.GetValue() match newV with | ValueSome u -> let mutable old = Unchecked.defaultof<'U> if state.Data.TryGetValue(kvp.Key, &old) && EqualityComparer<'U>.Default.Equals(old, u) then () // version bumped to an equal value: no delta else state.Data[kvp.Key] <- u state.Out.Sets <- Collections.bufferAppend state.Out.Sets (struct (kvp.Key, u)) changed <- true | ValueNone -> if state.Data.Remove kvp.Key then state.Out.Rems <- Collections.bufferAppend state.Out.Rems kvp.Key changed <- true entry.Version <- preV entry.Last <- newV cache[kvp.Key] <- entry if changed then state.Version <- state.Version + 1L if GraphContext.Default.WriteGeneration <> startGen then // A reentrant write landed mid-scan: the output may be stale, keep // the flag so the next read rescans. elementDirty <- true else elementDirty <- false /// Drain the journal, then the element scan, then push the accumulated /// output delta once, with notification delivery deferred. member private this.Process() = let ctx = GraphContext.Default let wasActive = ctx.TxActive ctx.TxActive <- true try if not state.Journal.IsEmpty then this.DrainJournal() if this.NeedsElementScan() then this.ScanElements() if not state.Out.IsEmpty then state.Version <- state.Version + 1L Collections.pushMapDelta &state.Sinks state.Out state.Out.Clear() finally ctx.TxActive <- wasActive // Capture AFTER the push (see ElementMapNode.Process). lastDrainWriteGen <- GraphContext.Default.WriteGeneration interface ICommittedVersion with member this.CommittedVersion = state.Version interface IMapDeltaSink<'K1, 'V1> with member this.OnDeltas ( setEntries: struct ('K1 * 'V1)[], setCnt: int, removedKeys: 'K1[], remCnt: int ) = if not disposed then Collections.journalAppendMap &state.Journal setEntries setCnt removedKeys remCnt state.Version <- state.Version + 1L GraphContext.Default.BumpWriteGeneration() interface IAdaptiveMap<'K1, 'U> with member this.GetValue() = let ctx = GraphContext.Default ctx.ClaimOwner() try if disposed then invalidOp "This adaptive map has been disposed." this.EnsureInitialized() if left.Version <> state.DepVersions[0] then left.GetValue() |> ignore state.DepVersions[0] <- Collections.committedVersion left this.Process() AdaptiveRuntime.addDependency (this :> IAdaptiveObject) state.Version state.Data :> IReadOnlyDictionary<'K1, 'U> finally ctx.ReleaseOwner() member this.Version = // Dirty indicator for version-checking readers (see ElementSetNode). if this.NeedsElementScan() then state.Version + 1L else state.Version interface IDisposable with member this.Dispose() = if not disposed then disposed <- true this.Unregister() Collections.clearSinks &state.Sinks interface IMapSinkRegistry with member this.AddMapSink(sink) = Collections.addSink &state.Sinks sink member this.RemoveMapSink(sink) = Collections.removeSink &state.Sinks sink