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
bindingsmap 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
npandmath - 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.
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; |
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 |
0
|
Raises:
| Type | Description |
|---|---|
SubscriptionLimitExceeded
|
If the client already holds |
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
|
t0_ns
|
int
|
Replacement window start; |
None
|
interval_ms
|
int
|
Replacement poll interval, floored to |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
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.