Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions .agent/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,17 @@ Architecture overview (the concept → type → package table and the execution

**Fan-out shares pointers.** Output→input fan-out forwards the same `*Signal` pointers to all destinations. Do not add deep-copy to `ForwardSignals` or `Flush`.

**No generics in data flow.** `signal`/`meta` use `any`/`float64`; FBP requires mixed-type signal flows in one group. Approved generics elsewhere: `hook.Group[T]` (typed hook registry) and `component.MustGetTyped[T]` (state accessor). Do not add more without approval.
**No generics in data flow.** `signal`/`meta` use `any`/`float64`; FBP requires mixed-type signal flows in one group. Approved generics elsewhere: `hook.Group[T]` (typed hook registry), `component.MustGetTyped[T]` (state accessor), and `signal.As[T]`/`signal.AsOrDefault[T]` (payload accessors — they read a payload out, they do not make the flow typed). Do not add more without approval.

**Minimise `reflect`.** Only when no alternative exists. Current approved use: `reflect.TypeOf(payload).Comparable()` in `ContainsPayload` — always nil-guard before calling `.Comparable()`.

## Package notes

- **`signal`** — `payload` is `[]any{value}` (single-element slice so `nil` is valid). Predicate combinators and label constructors live in `predicates.go`. `ForEach` returns `(*Group, error)`.
- **`signal`** — `payload` is `[]any{value}` (single-element slice so `nil` is valid). Predicate combinators and label constructors live in `predicates.go`. `ForEach`/`ForEachIf` return `error` only (as on every collection type — see [naming.md](naming.md)). Typed payload accessors live in `typed.go`: `As[T]` (error on nil signal / missing payload / wrong type), `AsOrDefault[T]`, fallible per-type shorthands over `As`, and `AsNumber` (loose `(float64, bool)` widening — `float64`/`float32`/`int`/`int64`/`uint64`, `bool` as 1/0). None of them panic; that is the point of having them. There are deliberately **no** `AsIntOrDefault`-style shorthands: `AsOrDefault` infers `T` from the default and is shorter. The sole exception is `AsFloat64OrDefault`, which exists because an untyped `0` infers `int`, so `AsOrDefault(s, 0)` silently returns the default for a float64 payload — do not "restore symmetry" by adding the others back.
- **`meta`** — `Labels` (string k/v) and `Scalars` (string→float64). `Keys()`/`Values()` return sorted slices for determinism. `Merge(other)` is the one non-mutating method on both types. `Every(pred)` on empty = `true` (vacuous truth). `ForEach` returns `error`. Constructors: `NewLabels()`, `NewScalars()`.
- **`port`** — `Flush()` fans out then clears source. `PipeTo` is output→input only. Both return `error`. `PipeTo` validates direction at call time.
- **`component`** — `State` is `map[string]any`, persistent across cycles and across `Run`s (see [runtime.md](runtime.md)). Constructors use functional options: `component.New(name, opts...) (*Component, error)`. Ports come in two creation styles: name-based (`WithInputs`/`AddInputs`, `WithIndexedInputs("i", 1, 3)` → `i1..i3`) and attach-based (`AttachInputPorts` for pre-built `port.NewInput` ports with options). `LoopbackPipe(out, in)` wires a component to itself (such a mesh never stops naturally). `ErrWaitingForInputs`/`ErrWaitingForInputsKeep` are scheduler control-flow sentinels, not failures.
- **`port`** — `Flush()` fans out then clears source. `PipeTo` is output→input only. Both return `error`. `PipeTo` validates direction at call time. `wiring.go` holds the declarative multi-edge helpers: `Pipe`/`MultiPipe` (registers connections) and `Pair`/`MultiForward` (copies signals now); both name the failing edge and report nil ports instead of dereferencing them.
- **Name lookups are silently forgiving — helpers taking port names must not be.** `Collection.ByName` returns `nil` for a name no port has, `Collection.ByNames` skips such names entirely, and `AllHaveSignals()`/`Every()` on the resulting empty collection is vacuously `true`. So `ByNames("typo").AllHaveSignals()` reports *ready*. Any helper that accepts port names as strings must resolve every name before asking anything about signals, and report the name it could not resolve.
- **`component`** — `State` is `map[string]any`, persistent across cycles and across `Run`s (see [runtime.md](runtime.md)). Constructors use functional options: `component.New(name, opts...) (*Component, error)`. Ports come in two creation styles: name-based (`WithInputs`/`AddInputs`, `WithIndexedInputs("i", 1, 3)` → `i1..i3`) and attach-based (`AttachInputPorts` for pre-built `port.NewInput` ports with options). `LoopbackPipe(out, in)` wires a component to itself (such a mesh never stops naturally). `ErrWaitingForInputs`/`ErrWaitingForInputsKeep` are scheduler control-flow sentinels, not failures. `compose.go` holds the `ActivationFunc` combinators — `Sequential`, `When`+`HasSignalsOn`, `RequireInputs`, `Pipeline`+`PipelineStage` — which compose a component's *own* activation (contrast `OnActivation` hooks, which are for behavior added from outside; see [hooks.md](hooks.md)). `When` skips, `RequireInputs` suspends and keeps: never substitute one for the other, as skipping where waiting was meant drops the partial inputs at drain time.
- **`hook`** — lives at `internal/hook` (not public API). Generic `hook.Group[T]`, ordered, fail-fast `Trigger`. Three hook levels (mesh/component/port); see [hooks.md](hooks.md).
- **`cycle`** — has its own `Any`/`Every`/`Count` on its collection type, independent of `signal.Group`.

Expand Down Expand Up @@ -59,6 +60,23 @@ Comments must add information beyond the signature. Omit a comment entirely rath
- No usage guidance ("Use X to do Y") or examples in type definition comments; those belong in method godocs or external docs
- Method comments: one line where possible

**Length — short and on point:**

One line is the default. A rationale that genuinely needs more gets a second short paragraph of
two or three sentences, and that is the ceiling.

- **State the constraint, not the story.** "A name no port has fails the activation instead of
suspending forever" beats a paragraph reconstructing how a reader might get it wrong.
- **No file-header essays.** A file-level comment names what the file holds in a sentence. If a
file needs several paragraphs to introduce itself, the material is documentation, not a comment.
- **A paragraph belongs in `docs/wiki/`.** The wiki teaches — narrative, examples, when-to-use-which
tables. Godoc reminds. Name the concept in the comment and let the page carry it; duplicating it
in source means two things to keep in sync, and the source copy is the one that rots.
- **Cut the prose voice.** Comments that argue with the reader ("which is the right number", "and
that is the point", "the whole reason this exists") are essay, not documentation.
- Explain the **non-obvious**: an invariant, a footgun, an ordering requirement, a reason the
obvious implementation is wrong. Never narrate what the next line plainly does.

## Dead code policy

Do not keep unused exported symbols "for future use". Remove them immediately:
Expand Down
4 changes: 4 additions & 0 deletions .agent/docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ closures are the only registration path.
- **`OnActivation` is special**: its hooks are `ActivationFunc`s appended after the main
activation function and run **sequentially in the same activation** — they share the error
path (first error aborts the chain and becomes the activation error).
- **`OnActivation` hooks vs activation combinators**: `component/compose.go` also chains
`ActivationFunc`s, but those are combinators — plain values passed to `WithActivationFunc`,
with no registry, no name, and no initialization step. Use a hook when something *outside*
the component adds behavior to it; use a combinator when the component composes its own.
- **`AfterActivation` always runs** — success, error, panic, or waiting; a `finally` block.
- Outcome hooks (`OnSuccess`/`OnError`/`OnPanic`/`OnWaitingForInputs`) fire before
`AfterActivation`. Distinguish waiting modes via `ctx.Result.Code()`
Expand Down
10 changes: 10 additions & 0 deletions .agent/docs/naming.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ need to wrap `error` where nothing can go wrong.
Prefer combinators over inline closures: `Not`, `And`, `Or`, `HasLabel`, `LabelEquals`,
`LabelContains`, `HasAllLabels`, `HasAnyLabel`.

Component-level: `component.HasSignalsOn(names...)` returns `func(*Component) bool`, the predicate
`component.When` takes.

## Combinators

Functions that take and return an `ActivationFunc` are named after what they do to the activation,
with no prefix: `Sequential`, `When`, `RequireInputs`, `Pipeline`. `With*` stays reserved for
constructor options, so `WithActivationFunc(Sequential(...))` reads as option-wrapping-value rather
than two options.

## Stuttering

Do not repeat the package name in a type or function name. Within the `meta` package, use
Expand Down
3 changes: 3 additions & 0 deletions .agent/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Shared implementations live in `internal/testutil` (usable from `integration_tests/` and other
external test packages); in-package tests of `fmesh` and `port` keep local copies (import cycle)
- Use `assert.InDelta` for float64 comparisons (tolerance `1e-9` for exact values, larger for computed averages)
- Comments explain why a case exists (the bug it pins down), never what the assertion does — one or two lines, same brevity rule as source (see [design.md](design.md))

## What to cover

Expand All @@ -19,3 +20,5 @@
- Cross-entity aggregation on `signal.Group`: `AvgScalar`/`MinScalar`/`MaxScalar` return `signal.ErrScalarNotFoundInGroup` when no element has the named scalar; `SumScalar` returns 0
- Group metadata separation: group's own Labels/Scalars must not bleed into element Labels/Scalars and vice versa
- `signal.Group` batch methods (`WithLabelOnEach`, `WithScalarOnEach`, etc.) must preserve the group's own metadata on the returned group
- Anything taking a port name as a string: cover the name that resolves to no port. An unresolved name reaches the assertion as an empty collection (vacuously ready) or a nil port (a panic at the first dereference), so the passing test proves nothing unless it names a port that does not exist
- Typed payload accessors: a wrong payload type, a nil payload, and a nil signal must all return an error or the default — never panic
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ func run() error {
component.WithInputs("i1", "i2"),
component.WithOutputs("res"),
component.WithActivationFunc(func(this *component.Component) error {
word1 := this.InputByName("i1").Signals().FirstPayloadOrDefault("").(string)
word2 := this.InputByName("i2").Signals().FirstPayloadOrDefault("").(string)
word1 := signal.AsOrDefault(this.InputByName("i1").Signals().First(), "")
word2 := signal.AsOrDefault(this.InputByName("i2").Signals().First(), "")
return this.OutputByName("res").PutSignals(signal.New(word1 + word2))
}))
if err != nil {
Expand All @@ -71,7 +71,7 @@ func run() error {
component.WithInputs("i1"),
component.WithOutputs("res"),
component.WithActivationFunc(func(this *component.Component) error {
str := this.InputByName("i1").Signals().FirstPayloadOrDefault("").(string)
str := signal.AsOrDefault(this.InputByName("i1").Signals().First(), "")
return this.OutputByName("res").PutSignals(signal.New(strings.ToUpper(str)))
}))
if err != nil {
Expand Down
111 changes: 111 additions & 0 deletions component/compose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package component

import (
"fmt"

"github.com/hovsep/fmesh/signal"
)

// Combinators over ActivationFunc: they build the single function a component is
// constructed with. Use OnActivation hooks instead when the behavior is added
// from outside the component rather than composed by it.

// Sequential runs activation functions in order, stopping at the first error.
//
// Errors pass through as the component's own, so a stage returning
// ErrWaitingForInputs suspends the component.
func Sequential(funcs ...ActivationFunc) ActivationFunc {
return func(this *Component) error {
for _, f := range funcs {
if err := f(this); err != nil {
return err
}
}
return nil
}
}

// When runs fn only if the predicate holds.
func When(predicate func(*Component) bool, fn ActivationFunc) ActivationFunc {
return func(this *Component) error {
if !predicate(this) {
return nil
}
return fn(this)
}
}

// HasSignalsOn reports whether every named input port exists and carries
// something. A name no port has is never ready; RequireInputs reports it.
func HasSignalsOn(portNames ...string) func(*Component) bool {
return func(this *Component) bool {
for _, name := range portNames {
p := this.InputByName(name)
if p == nil || !p.HasSignals() {
return false
}
}
return true
}
}

// RequireInputs suspends the component until every named port has signals,
// keeping whatever has already arrived. Contrast When, which skips the
// activation and so lets the partial inputs be cleared.
//
// A name no port has fails the activation instead of suspending it forever, and
// every name is checked before any signal, or an empty port hides the typo
// behind a wait.
func RequireInputs(portNames ...string) ActivationFunc {
return func(this *Component) error {
for _, name := range portNames {
if this.InputByName(name) == nil {
return fmt.Errorf("required input port %q does not exist", name)
}
}

if !this.Inputs().ByNames(portNames...).AllHaveSignals() {
return ErrWaitingForInputsKeep
}
return nil
}
}

// PipelineStage transforms a group of signals on its way through a pipeline.
type PipelineStage func(signals *signal.Group) (*signal.Group, error)

// Pipeline reads the named input ports in the order given, passes their signals
// through each stage in turn, and writes the result to one output port.
//
// A port name no port has, and a nil group from a stage, are errors rather than
// nil dereferences. A stage that drops everything returns an empty group.
func Pipeline(inputPortNames []string, outputPortName string, stages ...PipelineStage) ActivationFunc {
return func(this *Component) error {
out := this.OutputByName(outputPortName)
if out == nil {
return fmt.Errorf("pipeline output port %q does not exist", outputPortName)
}

signals := signal.NewGroup()
for _, name := range inputPortNames {
in := this.InputByName(name)
if in == nil {
return fmt.Errorf("pipeline input port %q does not exist", name)
}
signals = signals.With(in.Signals().All()...)
}

for i, stage := range stages {
next, err := stage(signals)
if err != nil {
return fmt.Errorf("pipeline stage %d failed: %w", i, err)
}
if next == nil {
return fmt.Errorf("pipeline stage %d returned a nil group", i)
}
signals = next
}

return out.PutSignalGroups(signals)
}
}
Loading