Skip to content

Fox architecture

This page documents Fox's internal design decisions and evaluation pipeline for developers working on or extending the module.

Why POST for evaluate

The evaluate endpoint uses POST rather than GET because:

  • Formula expressions can be arbitrarily long and contain special characters ({, }, **, ||)
  • The bindings map and mode configuration form a structured request body
  • GET query strings have practical length limits (~2000 characters) that expressions can exceed
  • POST avoids URL-encoding complexity for mathematical expressions

Why validate is separate

The validate endpoint exists independently from evaluate because:

  • No data retrieval — validation only parses and typechecks the AST; it never touches Vole, Mole, or Hare
  • No computation — the expression is never evaluated, so there's no timeout risk
  • UI use case — formula-maker interfaces call validate on every keystroke for real-time error feedback; this must be cheap and fast

Evaluation pipeline

FormulaSpec
    │
    ▼
Parser (formula_service.py --> parser.py)
    ├── Regex-extract {variable} tokens
    ├── Validate all tokens have bindings (and no extras)
    ├── Replace tokens with placeholders (__v0, __v1, ...)
    ├── Normalize booleans (&& --> and, || --> or, ! --> not)
    ├── ast.parse() in eval mode
    ├── Validate AST against ALLOWED_NODES + SAFE_FUNCS
    └── Detect numexpr compatibility
    │
    ▼
ParsedFormula {expression, tokens, placeholder_expr, is_numexpr_compatible}
    │
    ▼
Evaluator dispatch (by spec.kind)
    ├── ScalarEvaluator  --> read_latest per PV, eval once
    ├── TimeseriesEvaluator --> read_aligned window, eval over arrays
    └── WaveformEvaluator --> read_latest waveform arrays, eval element-wise
    │
    ▼
FormulaResult (tagged union: scalar | timeseries | waveform)

Numexpr fast path

When a formula uses only functions and operators that numexpr supports, the timeseries evaluator delegates to numexpr.evaluate() instead of Python/NumPy eval. This provides significant speedups for large arrays. The parser detects compatibility by checking the AST against NUMEXPR_FUNCS and mapping function names (e.g., asin --> arcsin).

WebSocket design

Subscribe-per-formula model

Each WebSocket client can hold multiple independent subscriptions. A subscription is identified by a server-assigned UUID and tracks one formula spec with its time window and update mode.

Shared eval loops

Subscriptions with identical parameters (spec + t0Ns + intervalMs) are deduplicated into a single SharedEval. The eval loop runs once and fans results out to all listeners. This prevents redundant computation when many dashboard panels watch the same formula.

A SHA-256 hash of the serialized parameters serves as the deduplication key.

Fan-out

When a shared eval produces a result, it iterates over all listeners and sends the result to each. Dead listeners (send failures) are pruned from the list automatically.

SubscriptionManager lifecycle

add_subscription()
    ├── t1_ns != 0? --> _one_shot(): eval once, send result + completed, done
    ├── intervalMs > 0? --> _poll_loop(): eval on timer
    └── else --> _eval_loop(): eval on PV change (push mode)

update_subscription()
    ├── Detach listener from old SharedEval
    ├── If old SharedEval has no listeners --> teardown (cancel task, unsub from Vole)
    └── Attach listener to new/existing SharedEval with updated params

remove_subscription() / remove_client()
    ├── Detach listener(s)
    └── Teardown orphaned SharedEvals

Evaluator internals

ScalarEvaluator

  • Reads the latest sample from each bound PV via read_latest()
  • Builds a namespace mapping placeholder names --> scalar values
  • Evaluates the expression once with Python eval() in a restricted namespace
  • Returns a single value with the most recent timestamp and worst-case severity

TimeseriesEvaluator

  • Reads aligned arrays for all bound PVs via read_aligned()
  • Builds a namespace mapping placeholder names --> NumPy arrays
  • Uses numexpr (if compatible) or NumPy eval for array computation
  • Optionally decimates the output if it exceeds max_points
  • Returns parallel arrays of timestamps, values, and severities

WaveformEvaluator

  • Reads the latest waveform (array-valued sample) from each PV
  • Builds a namespace mapping placeholder names --> 1-D NumPy arrays
  • Evaluates element-wise across the waveform
  • Returns a single timestamped array result

Security model

Fox enforces a strict sandbox on formula expressions:

AST allowlist

Only these ast node types are permitted:

  • Literals (numbers, strings, booleans, None)
  • Arithmetic, comparison, and boolean operators
  • Unary operators (+, -, not)
  • Function calls (restricted to SAFE_FUNCS)
  • Conditional expressions (ternary if/else)
  • Tuples (for multi-arg functions like atan2(x, y))
  • Name references (variables and constants)

Blocked constructs

  • import / from ... import
  • Lambda expressions
  • List/set/dict comprehensions and generator expressions
  • Subscript access (x[0], d["key"])
  • Attribute access on anything other than np and math
  • Any attribute starting with __ (dunder blocking)

Function allowlist (SAFE_FUNCS)

Only functions explicitly listed in SAFE_FUNCS (defined in namespace.py) can be called. Attempts to call unlisted functions produce a FormulaUnsafeError. The allowlist is intentionally conservative — it includes mathematical functions but excludes anything with side effects or filesystem/network access.

Module reference

Contracts

canopy.contracts.formula

Shared formula domain models.

Used by Fox (the evaluator), and eventually Owl (persists formula definitions), Squirrel (delta-vs-snapshot), and notebooks (ad-hoc evaluation).

FormulaSpec

Bases: CanopyModel

A complete formula request.

Owl stores these in its plot configs; Fox accepts them as input.

FormulaResult

Bases: CanopyModel

Tagged union by kind. Frontend discriminates on this field.

FormulaValidationResult

Bases: CanopyModel

Result of a parse+typecheck without evaluation.

Gateway

canopy.fox.gateway

Public surface of the Fox module for in-process callers.

Other Canopy modules import only from this file; the rest of canopy.fox.* is private implementation. Each function is a thin facade over FormulaService.

bind() wires the service singleton at app startup; get_service() is exposed for dependencies.py. Tests can re-bind a fake service freely.

bind(service)

Wire (or unwire) the FormulaService singleton.

bind_subscription_manager(mgr)

Wire (or unwire) the SubscriptionManager singleton.

evaluate(spec, *, t0_ns, t1_ns) async

Pure function: evaluate spec over the given window.

Pulls inputs from the data source (Vole). Raises FormulaInputMissing if any binding's PV is not buffered.

validate(spec) async

Parse + AST-check without touching the data source. Cheap; no I/O.

Services

FormulaService

canopy.fox.services.formula_service

FormulaService: orchestrates parse + evaluate by formula kind.

Lifecycle: one instance per canopy-api process. Constructed in canopy.fox.app.fox_lifespan; injected into canopy.fox.gateway via the module-level singleton there. Tests can re-bind freely.

FormulaService(data_source, parser, config)

Public business logic. Routes call this; gateway facades wrap this.

validate(spec) async

Parse + AST-check without touching the data source.

SubscriptionManager

canopy.fox.services.subscription_manager

Manages live formula subscriptions over WebSocket connections.

Multiple clients subscribing to the same formula share a single eval loop (SharedEval). Results are fanned out to all listeners. This deduplicates computation when many clients watch the same formula.

SubscriptionManager(formula_service, on_change_fn, subscribe_live_fn, subscribe_latest_fn=None, *, min_interval_ms=50, max_subscriptions_per_client=1000)

Orchestrates live formula subscriptions for WebSocket clients.

Deduplicates evaluation: subscriptions with identical parameters share one eval loop and fan out results to all listeners.

add_subscription(client_id, sub_id, spec, t0_ns, t1_ns, send_fn, *, interval_ms=0) async

Register a formula subscription.

If t1_ns is non-zero (fixed historical window), evaluates once and sends the result without opening a live subscription. If interval_ms > 0, uses poll mode (timer-based) instead of push mode.

Multiple clients with identical parameters share one eval loop. A subscription that joins an already-running push-mode loop is caught up on the current value by _join_shared.

Parameters:

Name Type Description Default
client_id str

WebSocket client that owns the subscription.

required
sub_id str

Client-chosen subscription id; re-using a live id replaces it.

required
spec FormulaSpec

Formula to evaluate.

required
t0_ns int

Window start; 0 selects the latest value and a negative value is relative to the evaluation time.

required
t1_ns int

Window end; non-zero requests a single historical evaluation.

required
send_fn SendFn

Coroutine that delivers message frames to the client.

required
interval_ms int

Poll interval in milliseconds, floored to min_interval_ms; 0 (default) selects push mode.

0

Raises:

Type Description
SubscriptionLimitExceeded

If the client already holds max_subscriptions_per_client live subscriptions and sub_id is new.

Exception

Any error raised while starting a new shared eval, after the partially started eval is torn down.

update_subscription(client_id, sub_id, send_fn, *, spec=None, t0_ns=None, interval_ms=None) async

Update a subscription's parameters.

Detaches the listener from its current SharedEval and moves it to the one matching the updated params, starting it if none is running or joining it through _join_shared otherwise. Other listeners on the old SharedEval are unaffected. An update that resolves to the current SharedEval leaves the subscription as it is.

Parameters:

Name Type Description Default
client_id str

WebSocket client that owns the subscription.

required
sub_id str

Subscription id to update.

required
send_fn SendFn

Coroutine that delivers message frames to the client.

required
spec FormulaSpec

Replacement formula; None keeps the current one.

None
t0_ns int

Replacement window start; None keeps the current one.

None
interval_ms int

Replacement poll interval, floored to min_interval_ms; None keeps the current one.

None

Returns:

Type Description
bool

False if the subscription does not exist, otherwise True.

Raises:

Type Description
Exception

Any error raised while starting a new shared eval; the old eval and the subscription's mapping are left intact.

Parser

canopy.fox.services.parser

Expression parser: text → validated ParsedFormula.

Steps: 1. Regex-lift {...} tokens → placeholder identifiers (__v0, __v1, ...) 2. Validate all tokens have corresponding bindings; reject nested braces 3. ast.parse the placeholder-substituted expression 4. Walk AST against ALLOWED_NODES + SAFE_FUNCS 5. Detect numexpr compatibility 6. Return ParsedFormula

ParsedFormula(expression, tokens, placeholder_expr, is_numexpr_compatible) dataclass

Result of a successful parse.

Parser(max_expression_len=2048)

Stateless expression parser with LRU caching.

parse(expression, bindings)

Parse and validate an expression against its bindings.

extract_tokens(expression)

Extract {name} tokens without full validation.

Schemas

canopy.fox.schemas.formula

Fox wire DTOs for the formula endpoints.

EvaluateRequestDTO

Bases: CanopyModel

Request body for POST /fox/v1/evaluate.

FormulaResultDTO

Bases: CanopyModel

Discriminated union by kind.

FormulaValidationDTO

Bases: CanopyModel

Response from POST /fox/v1/validate.