namespace Mibo.Adaptive
open System
open System.Collections.Generic
// Per-element adaptive set node (docs/2026-08-05-MAPA-DESIGN.md)
//
// ASet.mapA / chooseA / filterA share one node: the mapping returns an
// adaptive value per element, cached by input element. Structural source
// changes go through the journal (existing pattern, cache maintained per op);
// element-aval changes go through a version scan of the cache, gated on the
// write generation: a scan runs only when a write landed since the last one.
// =============================================================================
///
/// Maps every element of a set to an adaptive value (or chooses/filters, when
/// the aval's value is ValueNone to drop the element). Duplicate
/// output values share one reference count (refcounted set).
///
type ElementSetNode<'T, 'U when 'T: equality and 'U: equality>
(source: IAdaptiveSet<'T>, [] mapping: 'T -> aval<'U voption>)
=
let mutable state = SetNodeState<'T, 'U>.Create(1)
let mutable cache = Dictionary<'T, ElementEntry<'U>>()
let mutable initialized = false
let mutable disposed = false
// Self-healing flag: set when a reentrant write lands mid-scan, so the
// next read rescans even when the generation did not move since.
let mutable elementDirty = false
// Write generation at the last scan. The generation gate fires when it
// moved since.
let mutable lastDrainWriteGen = -1L
member private this.Register() =
match box source with
| :? ISetSinkRegistry as r -> r.AddSetSink(box(this :> ISetDeltaSink<'T>))
| _ -> ()
member private this.Unregister() =
match box source with
| :? ISetSinkRegistry as r ->
r.RemoveSetSink(box(this :> ISetDeltaSink<'T>))
| _ -> ()
/// 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: the mapping is user
// code that may write to the source (the transient view must not
// be iterated while it is mutated), and the write must land in
// the journal (register before the mapping runs). The flag is set
// last: an exception leaves the node uninitialized.
let snapshot = HashSet<'T>(source.GetValue())
this.Register()
for item in snapshot do
let aval = mapping item
// 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 ->
let struct (s2, _) = Collections.refAdd state.Set u
state.Set <- s2
| ValueNone -> ()
cache[item] <- entry
state.DepVersions[0] <- Collections.committedVersion source
initialized <- true
/// Apply the source journal with cache maintenance: removed elements drop
/// their cache entry (unregistered) and their contribution; added elements
/// run the mapping, force the aval, and contribute.
member private this.DrainJournal() =
let rems = state.Journal.Rems
let adds = state.Journal.Adds
let remStart = rems.Count
let addStart = adds.Count
let mutable i = 0
// Suspend the append-time cross-kind cancellation while the journal
// is replayed: a removal could shift entries under the captured
// indexes and double-process them (see journalAppendSet).
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 addsDone = 0
try
while i < remStart do
let x = rems.Items[i]
let mutable entry = Unchecked.defaultof>
if cache.TryGetValue(x, &entry) then
cache.Remove x |> ignore
match entry.Last with
| ValueSome old ->
let struct (s2, removed) = Collections.refRemove state.Set old
state.Set <- s2
if removed then
state.Out.Rems <- Collections.bufferAppend state.Out.Rems old
| ValueNone -> ()
i <- i + 1
remsDone <- i
i <- 0
while i < addStart do
let x = adds.Items[i]
let aval = mapping x
// Version read BEFORE the force (see EnsureInitialized).
let preV = aval.Version
let newV = aval.GetValue()
let entry = ElementEntry(aval, preV, newV)
match newV with
| ValueSome u ->
let struct (s2, added) = Collections.refAdd state.Set u
state.Set <- s2
if added then
state.Out.Adds <- Collections.bufferAppend state.Out.Adds u
| ValueNone -> ()
cache[x] <- entry
i <- i + 1
addsDone <- i
finally
state.Journal.InDrain[0] <- 0
// Compact against the LIVE journal counts: reentrant writes during
// the mapping append to the live journal (a stale copy would drop
// them). Consumed entries are dropped; the throwing entry survives.
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 addLive = state.Journal.Adds.Count
if addLive > addsDone then
Array.Copy(
state.Journal.Adds.Items,
addsDone,
state.Journal.Adds.Items,
0,
addLive - addsDone
)
state.Journal.Adds.Count <- addLive - addsDone
else
state.Journal.Adds.Count <- 0
/// The element scan: re-read every cached aval's version; force the
/// changed ones and apply the contribution change through the refcounted
/// set. 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 entry.Last with
| ValueSome old ->
match newV with
| ValueSome newU ->
if EqualityComparer<'U>.Default.Equals(old, newU) then
() // version bumped to an equal value: no delta
else
let struct (s2, removed) = Collections.refRemove state.Set old
state.Set <- s2
if removed then
state.Out.Rems <- Collections.bufferAppend state.Out.Rems old
let struct (s3, added) = Collections.refAdd state.Set newU
state.Set <- s3
if added then
state.Out.Adds <- Collections.bufferAppend state.Out.Adds newU
changed <- true
| ValueNone ->
let struct (s2, removed) = Collections.refRemove state.Set old
state.Set <- s2
if removed then
state.Out.Rems <- Collections.bufferAppend state.Out.Rems old
changed <- true
| ValueNone ->
match newV with
| ValueSome newU ->
let struct (s2, added) = Collections.refAdd state.Set newU
state.Set <- s2
if added then
state.Out.Adds <- Collections.bufferAppend state.Out.Adds newU
changed <- true
| ValueNone -> ()
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.
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.pushSetDelta &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 ISetDeltaSink<'T> with
member this.OnDeltas(adds: 'T[], addCnt: int, rems: 'T[], remCnt: int) =
if not disposed then
Collections.journalAppendSet &state.Journal adds addCnt rems remCnt
state.Version <- state.Version + 1L
GraphContext.Default.BumpWriteGeneration()
interface IAdaptiveSet<'U> with
member this.GetValue() =
let ctx = GraphContext.Default
ctx.ClaimOwner()
try
if disposed then
invalidOp "This adaptive set 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.Set.Data :> IReadOnlySet<'U>
finally
ctx.ReleaseOwner()
member this.Version =
// Dirty indicator for version-checking readers: a pending element
// scan is reported as a version bump (the scan itself advances the
// version only when the output changes).
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 ISetSinkRegistry with
member this.AddSetSink(sink) = Collections.addSink &state.Sinks sink
member this.RemoveSetSink(sink) =
Collections.removeSink &state.Sinks sink