/** * rgui core — the SIGNAL ALGEBRA: what a wire carries, and what happens to it * when wires fork (one output → many edges) or converge (many edges → one input). * * `MergeRule` (aggregate.ts) already answers "how do FIELD VALUES combine when * nodes renormalize into a block". This module answers the same question one * level down, for the DATA on the ports — and adds the question renormalization * never had to ask: what happens on the way OUT. * * ## Three questions, three owners * * MEASURE — is `+` meaningful across parallel sources? owned by: the port * OWNERSHIP — MAY this value be duplicated / aliased? owned by: the producing port * FANOUT — is it duplicated HERE, or divided? owned by: the fan-out group * * `ownership` and `fanout` are deliberately separate. One says what the data IS * (a capability, intrinsic, not overridable downstream); the other says what * this particular topology DOES with it (a policy, chosen per fan-out site). * Conflating them cannot express "a 4K frame may be copied, but broadcasting it * to three consumers on three machines means serializing it three times". * * Note what is NOT here: transport. Whether a value can cross a machine or * process boundary depends on where the nodes LAND, and only the host knows that. * rgui says whether a value may be duplicated (`isDuplicable`); the host composes * that with its own placement. Every verdict rgui reaches is placement-independent. * * ## Why measure and ownership are two axes, not one * * The tempting model is a single axis — "change vs state", where a change is * additive and splittable and a state is neither. It is the right intuition and * the wrong factorization. It breaks in both directions: * * - A cumulative counter (`requests = 1523`) is additive across shards, yet on * fan-out it must be COPIED. Two downstream dashboards each see 1523; you do * not hand one of them 700 and the other 823. * - A transfer of 100 coins is additive AND must never be copied — duplicating * it mints money. * * Physics makes the same distinction and keeps the words apart: mass is extensive * and conserved; ENTROPY is extensive and emphatically not conserved; volume is * extensive and not conserved either. Additivity and conservation are orthogonal * even in the theory the vocabulary is borrowed from. * * │ copy / clone / share │ move * ─────────────┼──────────────────────────────┼─────────────────────────────── * extensive │ STT transcript segments, │ token budget, money, work * (sum/concat) │ audio chunks, log lines, │ items, rows of a batch * │ shard counters │ * ─────────────┼──────────────────────────────┼─────────────────────────────── * intensive │ coordinates, image frames, │ an exclusive lease, a GPU slot, * (no sum) │ vision labels, MediaStream │ a lock token * * The top-left cell is the one the single-axis story gets wrong. An STT node's * transcript is a CHANGE (concatenating successive segments is exactly right) but * wiring it to both a translator and a subtitle sink must give each the WHOLE * sentence. It is not indivisible-so-route-it-somewhere; it is a fact, and facts * are free to copy. Round-robining a transcript between two consumers would be a * bug. Splitting belongs to resources, not to changes. * * ## Why `sum` is refused on an intensive port * * Positions form a *torsor* over the vector space of displacements: `a + b` is * meaningless, `a - b` is a displacement (which IS extensive), and `mean(a, b)` is * fine because its coefficients sum to 1 — an affine combination, not a linear * one. That is the whole reason the legality table below gates `sum`/`concat` and * nothing else: every other rule (mean, median, min, max, mode, set, first, last) * is either a selection or an affine combination, and both are legal on a state. * * rgui does NOT execute graphs — the host does. rgui's job is to let a port * DECLARE its algebra, to VALIDATE wiring against it, and to RENDER the * difference (a split wire does not look like a broadcast wire). Execution * combinators here are pure, dependency-free reference semantics the host may * use or ignore. * * ## Mapping to sflow (snomiao/sflow — WebStreams; vendored at lib/sflow) * * fanout "broadcast" → `tees()` / `.fork()` (ReadableStream.tee — duplicates) * fanout "route" → `distributeBys(fn)` (each chunk to exactly ONE branch) * `confluences({order:"breadth"})` for the fair/round-robin case * fanout "split" → no sflow equivalent; conservation is what rgui adds here. * For grain "atom", compose `lines()` (atom boundaries) with a * distribute step. * merge extensive → `merges()` / `parallels()` (interleave — concat semantics) * merge intensive → `toLatests()` (i.e. MergeRule "last") */ import type { MergeRule } from "./aggregate.js"; import type { Graph, Port, SignalKind } from "./graph.js"; /** * FAN-IN: is addition meaningful when several sources converge? * * - "extensive": summing/concatenating parallel sources is meaningful. Counts, * durations, transcript segments, audio chunks. * - "intensive": addition is nonsense; only selection (mode/first/last/min/max) * or affine combination (mean/median) may merge. Coordinates, frames, label * sets, temperatures, sample rates. */ export type Measure = "extensive" | "intensive"; /** * OWNERSHIP — may this value be duplicated, and may several consumers hold it at * once? Owned by the PRODUCING port and not overridable downstream: only the node * emitting a value knows whether it hands out a coordinate or a MediaStream * handle. Substructural, in the type-theory sense — "copy" permits contraction, * "move" forbids it, and "share" sits between them. * * These are Rust's four, and for the same reasons: * * - "copy": `Copy`. Duplication is free. Coordinates, labels, config, a transcript. * - "clone": `Clone`. Duplication is legal but COSTS. A 4K frame, a PCM chunk — * broadcasting one to three consumers on three machines serializes and ships it * three times. Legal, and worth saying out loud. * - "share": `Arc` / `&T`. The value CANNOT be duplicated, but several * consumers may hold the same one. A MediaStream, a GPU buffer, an * OffscreenCanvas. Handing it to two downstream nodes in one process is a * shared borrow, not a copy — so broadcasting it is LEGAL. * - "move": single ownership. The value may be held by exactly one consumer. * Either duplicating it double-spends it (money, a token budget, a work item) * or aliasing it breaks exclusivity (a lease, a lock). A "move" port may never * broadcast. * * The "share" rung is what lets rgui judge a fan-out WITHOUT knowing placement. * A shared reference is unsafe to duplicate but safe to alias, so its broadcast * is legal everywhere; a "move" is illegal everywhere. Neither verdict depends on * which machine a node lands on. Whether a non-duplicable value can CROSS a * device boundary is a transport question, and transport belongs to the host — * see `isDuplicable`, which is the predicate a host needs for exactly that check. */ export type Ownership = "copy" | "clone" | "share" | "move"; /** * POLICY — what one output port does when it feeds several edges. Owned by the * FAN-OUT GROUP (the set of edges leaving that port), not by any single edge: * you cannot have one edge of a group broadcast while another splits, because * conservation is a property of the whole division. The port carries the group's * default; `Graph.fanout` overrides it per group; `Edge.weight` tunes the shares * within a split. * * - "broadcast": every downstream receives the whole value. Illegal on "move". * - "split": the value is divided; the parts sum back to the whole. Needs a * `grain` (where cuts are legal). Legal on any share — dividing a copyable * value is a load-balancing choice, not a safety one. * - "route": the whole value goes to exactly ONE downstream. For indivisible * resources and work items. */ export type Fanout = "broadcast" | "split" | "route"; /** * Where a "split" may legally cut. The POLICY is a property of the type; the * actual boundaries are a property of the value (a JSONL blob is line-grained, * but only the value knows where its newlines are), so rgui carries the policy * and the host locates the atoms. */ export type Grain = /** divisible anywhere: numbers, a byte budget, a duration */ "continuous" /** divisible only at atom boundaries named by `atom` (lines, frames, rows) */ | "atom"; /** The algebra a port declares. Every field is optional on a Port; see DEFAULTS. */ export interface SignalSpec { measure: Measure; ownership: Ownership; fanout: Fanout; /** split only */ grain?: Grain; /** declarative atom-boundary tag for grain "atom": "line" | "frame" | "row" | … */ atom?: string; /** fan-in rule; defaults per `defaultMerge` */ merge?: MergeRule; } /** * Unmarked ports behave exactly as they did before this module existed: a wire * carries a value, nothing is summed, nothing is divided, and a second edge off * the same port simply broadcasts (as every node editor does). All three * defaults are the SAFE choice — copying a fact never destroys anything, and * refusing to sum never fabricates anything. */ export declare const DEFAULT_SIGNAL: SignalSpec; /** The only rules that require additivity — the entire content of the gate. */ export declare const ADDITIVE_RULES: readonly MergeRule[]; /** merge rules legal on a port of this measure (custom fns are always allowed) */ export declare function allowedMerges(measure: Measure): MergeRule[]; /** * Is this fan-in rule legal on this measure? Only `sum`/`concat` can be illegal: * they are the two rules that presuppose a monoid. Custom reducers opt out of * the check — the host asserted it knows what it is doing. */ export declare function isMergeLegal(rule: MergeRule, measure: Measure): boolean; /** * Can independent copies of this value be made? The predicate a HOST needs for * its transport check: a value that is not duplicable cannot be serialized across * a device or process boundary — it can only be used where it lives. rgui does * not know placement, so it never performs that check; it exports the predicate * and lets the host apply it to its own edges. */ export declare const isDuplicable: (o: Ownership) => boolean; /** * May several consumers hold this value at once? True for everything but "move". * A shared reference is unsafe to duplicate yet safe to ALIAS, which is exactly * why its broadcast is legal without knowing where anything runs. */ export declare const isAliasable: (o: Ownership) => boolean; /** * Is this fan-out policy permitted by the value's ownership? The single * constraint: a "move" value may not be broadcast, because broadcasting means * several consumers hold it at once and "move" is single-ownership. Everything * else is allowed — splitting a copyable value is a load-balancing decision, not * a safety one, and broadcasting a handle is a borrow, not a copy. * * Both verdicts are placement-independent. That is the whole point of the * "share" rung: rgui can decide them without knowing which machine a node * lands on. */ export declare function isFanoutLegal(ownership: Ownership, fanout: Fanout): boolean; /** single ownership — duplicating OR aliasing it violates a conservation law */ export declare const isConserved: (s: SignalSpec) => boolean; /** is duplicating it legal but expensive? (a warning, never an error) */ export declare const isCostlyToCopy: (s: SignalSpec) => boolean; /** resolve a port's declared algebra against the defaults */ export declare function resolveSignal(port: Port): SignalSpec; /** the key a fan-out group is addressed by: "nodeId.portId" */ export declare const fanoutKey: (nodeId: string, portId: string) => string; /** * The policy governing one fan-out group. The port declares the default; the * GRAPH may override it per group, because the same audio-segment port feeds a * recorder (broadcast) in one graph and a worker pool (route) in another. That * is a topology decision, and topology belongs to the graph. */ export declare function groupFanout(graph: Graph, nodeId: string, portId: string): Fanout; /** the edges leaving one output port, in graph order */ export declare function fanoutGroup(graph: Graph, nodeId: string, portId: string): import("./graph.js").Edge[]; /** * The fan-out weights of a group, normalized to sum 1. Per-EDGE, because only * the shares may differ within a group — the policy may not. An edge with no * `weight` counts as 1. */ export declare function groupWeights(graph: Graph, nodeId: string, portId: string): number[]; /** * The fan-in rule a port uses when it declares none. Extensive TEXT and AUDIO * concatenate (joining two transcript halves is the point); every other * extensive carrier sums; intensive ports take the latest value, matching * sflow's `toLatests()`. */ export declare function defaultMerge(kind: SignalKind, measure: Measure): MergeRule; /** the effective fan-in rule for a port */ export declare function portMerge(port: Port): MergeRule; /** normalize N weights to sum 1; missing/degenerate weights fall back to even */ export declare function normalizeWeights(n: number, weights?: number[]): number[]; /** * Split a continuous quantity so the parts sum EXACTLY back to `total` — the * final part absorbs the floating-point residue rather than letting it leak. */ export declare function splitQuantity(total: number, weights: number[]): number[]; /** * Split indivisible atoms across N downstreams, conserving the COUNT exactly * (largest-remainder / Hare quota — the apportionment method, because handing * out whole atoms in proportion to weights is literally apportionment). Atom * order is preserved: downstream i receives a contiguous run. */ export declare function splitAtoms(atoms: T[], weights: number[]): T[][]; /** * Round-robin destination for the `seq`-th indivisible chunk among `n` * downstreams — the fair, stateless "route" policy (sflow: `distributeBys` with * a counter, or `confluences({ order: "breadth" })` on the merge side). Hosts * wanting hash- or key-partitioning substitute their own index function. */ export declare function routeIndex(seq: number, n: number): number; /** split a text blob at "line" atom boundaries, newline kept with its line */ export declare function splitLines(text: string): string[]; /** * How a grain-"atom" value comes apart and goes back together: `atoms` lists the * indivisible pieces, `join` reassembles a subset of them into a value of the * same type. Together they say the value is a free monoid over its atoms, which * is precisely what makes a conserving split well defined — * `join(atoms(v)) === v`, and joining the parts of a split reproduces the whole. * * An atom need not have the value's own type (an atom of `Row[]` is a `Row`), * so the two type parameters stay separate. */ export interface Atomizer { atoms: (value: T) => A[]; join: (parts: A[]) => T; } /** text splits at line boundaries and rejoins by concatenation */ export declare const lineAtomizer: Atomizer; /** * Reference fan-out of one value to `n` downstreams under a port's algebra. * Returns `n` slots; a "route" fan-out fills exactly one and leaves the rest * `undefined` (nothing was sent there — which is the point). * * `seq` is the chunk's index in the output stream; only "route" reads it, and * it is passed in rather than counted here so this stays pure. */ export declare function forkValue(value: T, spec: SignalSpec, n: number, opts?: { weights?: number[]; seq?: number; atomizer?: Atomizer; }): (T | undefined)[]; export type SignalSeverity = "error" | "warn"; export interface SignalDiagnostic { severity: SignalSeverity; /** stable machine-readable code */ code: "sum-on-state" | "broadcast-move" | "cloned-fanout" | "kind-mismatch" | "grain-without-split" | "atom-without-grain" | "weight-without-split" | "unmerged-fan-in" | "copied-resource"; message: string; node?: string; port?: string; } /** * Check a graph's wiring against its declared signal algebra. Pure — returns * diagnostics rather than throwing, because a graph mid-edit is allowed to be * momentarily wrong and the canvas would rather draw the problem than crash. * * The two load-bearing checks are `sum-on-state` (a fan-in that would add up * values whose type has no addition) and `broadcast-move` (a fan-out that would * duplicate a value whose duplication is forbidden). */ export declare function checkSignals(graph: Graph): SignalDiagnostic[]; /** * A connection guard for `createRgui({ isValidConnection })`: refuses the edge * that would make a "move" port broadcast. Everything else is allowed — a * "clone" fan-out is legal (checkSignals warns about its cost), and a "copy" * fan-out is the everyday broadcast every node editor performs. * * This is where "single-to-single by default" actually bites, and it bites only * where duplication is unsafe rather than merely expensive. */ export declare function signalConnectionGuard(graph: () => Graph): (from: { node: string; port: string; }, to: { node: string; port: string; }) => boolean; type Preset = Omit & { kind: SignalKind; merge?: MergeRule; }; export declare const SIGNALS: { /** STT segments: concat-able across time, and a cheap FACT — broadcast to all */ readonly transcript: { readonly kind: "text"; readonly measure: "extensive"; readonly ownership: "copy"; readonly fanout: "broadcast"; }; /** vision labels ("person, chair"): a snapshot; concatenating frames is nonsense */ readonly labels: { readonly kind: "text"; readonly measure: "intensive"; readonly ownership: "copy"; readonly fanout: "broadcast"; }; /** line-delimited records shared out across workers, never cut mid-line */ readonly jsonl: { readonly kind: "text"; readonly measure: "extensive"; readonly ownership: "copy"; readonly fanout: "split"; readonly grain: "atom"; readonly atom: "line"; }; /** a single image: a state, and a BIG one — copying it costs */ readonly frame: { readonly kind: "image"; readonly measure: "intensive"; readonly ownership: "clone"; readonly fanout: "broadcast"; }; /** PCM chunks: concat-able in time, copied to recorder + STT, but not free */ readonly pcm: { readonly kind: "audio"; readonly measure: "extensive"; readonly ownership: "clone"; readonly fanout: "broadcast"; }; /** a position/setting: no addition, freely copied */ readonly coord: { readonly kind: "ctl"; readonly measure: "intensive"; readonly ownership: "copy"; readonly fanout: "broadcast"; }; /** * a live handle — MediaStream, GPU buffer, OffscreenCanvas, file descriptor. * It cannot be duplicated, but two downstream nodes in the same process may * hold it at once (a shared borrow), so broadcasting it is legal. It cannot * cross a device boundary — `isDuplicable` is false — but that verdict belongs * to the host, which is the only side that knows where the nodes run. */ readonly handle: { readonly kind: "ctl"; readonly measure: "intensive"; readonly ownership: "share"; readonly fanout: "broadcast"; }; /** a divisible allowance (tokens/sec, bytes): conserved, splits continuously */ readonly budget: { readonly kind: "ctl"; readonly measure: "extensive"; readonly ownership: "move"; readonly fanout: "split"; readonly grain: "continuous"; }; /** work items: additive in count, indivisible individually — round-robin them */ readonly work: { readonly kind: "ctl"; readonly measure: "extensive"; readonly ownership: "move"; readonly fanout: "route"; }; /** an exclusive lease/lock/slot: neither addable nor copyable */ readonly lease: { readonly kind: "ctl"; readonly measure: "intensive"; readonly ownership: "move"; readonly fanout: "route"; }; }; /** build a Port from a preset: `port("audio", "mic", SIGNALS.pcm)` */ export declare function port(id: string, label: string, preset: Preset): Port; export {}; //# sourceMappingURL=signal.d.ts.map