From 6edde9e7d8fab99248fb4c28d224f5254482dd7f Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Sun, 26 Jul 2026 08:21:44 -0400 Subject: [PATCH 1/3] feat(v1.0): static typing, look-ahead protection, indicators, VM, CLI Nano 1.0 core. The compiler emits the lowest IR version that can express a program, so all 21 .nano/_ir.json corpus pairs stay byte-identical and a host pinned to 0.1.0 keeps working; programs reaching past baseline become 1.0.0. - nano/types/: type system (series, confidence, assignability), semantic checker, and look-ahead protection. Series offsets count backwards and must fold to a non-negative constant, so close[t+1] and close[-1] are both compile errors with exact positions. - nano/indicators/: 33 typed indicator signatures plus deterministic kernels. Absent cells stay absent; recursive kernels re-seed across feed gaps rather than smoothing over them. - nano/ir/module.py: IR v1.0 as a flat typed DAG. Backward-only references, effects as capability grants, tier gating, and a moduleHash over executable content only -- separate from sourceHash so a comment edit changes one and not the other. - nano/runtime/vm.py: one evaluator for both IR versions. Baseline graphs lift via StrategyGraph.to_module(); a conformance test asserts the two paths agree bar-for-bar across the whole corpus. - nano/cli/: check, compile, replay, visualize, indicators, version, with documented exit codes and editor-parseable diagnostics. - nano/data/: the only module that reads a file. Timestamps parse as UTC, rows sort, duplicates are rejected, blank cells are absent rather than zero. Grammar gains tier/param/input/let/risk/signature/route, arithmetic with precedence, series indexing, else branches, and multiple rules per schedule. Tests 173 -> 268 passing. Co-Authored-By: Claude Opus 5 --- nano/__init__.py | 25 +- nano/aethercode/highlight.py | 56 ++- nano/cli/__init__.py | 26 + nano/cli/__main__.py | 5 + nano/cli/commands.py | 382 ++++++++++++++ nano/cli/main.py | 183 +++++++ nano/cli/render.py | 302 +++++++++++ nano/compiler/__init__.py | 55 +- nano/compiler/ast.py | 351 +++++++++++++ nano/compiler/codegen.py | 601 ++++++++++++++++++++-- nano/compiler/errors.py | 37 +- nano/compiler/legacy.py | 144 ++++++ nano/compiler/lexer.py | 82 +++ nano/compiler/parser.py | 871 +++++++++++++++++++++++++++----- nano/compiler/tokens.py | 11 +- nano/data/__init__.py | 32 ++ nano/data/frames.py | 287 +++++++++++ nano/indicators/__init__.py | 22 + nano/indicators/compute.py | 631 +++++++++++++++++++++++ nano/indicators/registry.py | 263 ++++++++++ nano/ir/__init__.py | 83 ++- nano/ir/graph.py | 109 +++- nano/ir/module.py | 594 ++++++++++++++++++++++ nano/ir/schema.py | 100 +++- nano/runtime/__init__.py | 16 + nano/runtime/vm.py | 548 ++++++++++++++++++++ nano/types/__init__.py | 74 +++ nano/types/checker.py | 945 +++++++++++++++++++++++++++++++++++ nano/types/env.py | 143 ++++++ nano/types/kinds.py | 164 ++++++ nano/types/lookahead.py | 138 +++++ pyproject.toml | 13 +- tests/test_aethercode.py | 12 +- tests/test_cli.py | 383 ++++++++++++++ tests/test_compiler.py | 34 +- tests/test_conformance.py | 37 ++ tests/test_types.py | 358 +++++++++++++ tests/test_vm.py | 331 ++++++++++++ 38 files changed, 8230 insertions(+), 218 deletions(-) create mode 100644 nano/cli/__init__.py create mode 100644 nano/cli/__main__.py create mode 100644 nano/cli/commands.py create mode 100644 nano/cli/main.py create mode 100644 nano/cli/render.py create mode 100644 nano/compiler/ast.py create mode 100644 nano/compiler/legacy.py create mode 100644 nano/data/__init__.py create mode 100644 nano/data/frames.py create mode 100644 nano/indicators/__init__.py create mode 100644 nano/indicators/compute.py create mode 100644 nano/indicators/registry.py create mode 100644 nano/ir/module.py create mode 100644 nano/runtime/vm.py create mode 100644 nano/types/__init__.py create mode 100644 nano/types/checker.py create mode 100644 nano/types/env.py create mode 100644 nano/types/kinds.py create mode 100644 nano/types/lookahead.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_types.py create mode 100644 tests/test_vm.py diff --git a/nano/__init__.py b/nano/__init__.py index d251ad3..056643c 100644 --- a/nano/__init__.py +++ b/nano/__init__.py @@ -1,7 +1,26 @@ """Nano — deterministic intelligence execution graphs. -IR-first: the execution graph (nano.ir) and reference runtime (nano.runtime) -come before the surface language (nano.compiler, Milestone 4). +IR-first: the execution graph (nano.ir) and reference runtime (nano.runtime) came +before the surface language (nano.compiler), and the IR is still the contract +every runtime speaks. + +The v1.0 layout: + +| Package | Role | +|---|---| +| ``nano.compiler`` | `.nano` -> IR: lexer, parser, codegen | +| ``nano.types`` | the type system, and look-ahead protection | +| ``nano.indicators`` | typed indicator signatures + deterministic kernels | +| ``nano.ir`` | both IR document versions, and the DAG runtimes execute | +| ``nano.runtime`` | the reference interpreter and the VM | +| ``nano.bridge`` | decision-gate adapter, backtester, optional provenance | +| ``nano.data`` | the one place that reads a file | +| ``nano.cli`` | check, compile, replay, visualize | +| ``nano.aethercode`` | editor language services | +| ``nano.memory`` / ``nano.loop`` | compiled-pattern cache, Nano++ loop IR | + +Kept dependency-free on purpose: `pip install aether-nano` pulls in nothing, so a +compiled artifact cannot change behavior because a transitive dependency did. """ -__version__ = "0.1.0" +__version__ = "1.0.0" diff --git a/nano/aethercode/highlight.py b/nano/aethercode/highlight.py index 356eae9..9311d59 100644 --- a/nano/aethercode/highlight.py +++ b/nano/aethercode/highlight.py @@ -21,22 +21,63 @@ KIND_ACTION = "action" KIND_INTERVAL = "interval" KIND_NUMBER = "number" +KIND_STRING = "string" KIND_OPERATOR = "operator" KIND_PUNCTUATION = "punctuation" KIND_IDENTIFIER = "identifier" -_KEYWORDS = frozenset({"strategy", "every", "if", "and", "agent"}) +# There is deliberately no "indicator" or "type" kind here. Both would require +# knowing what a name *means*, and this function runs on partial, unparseable +# source where that is unknowable: `RSI` in `RSI(14) < 30` is a host-supplied +# feed signal, while `RSI(close, 14)` is a computed indicator, and the two are +# spelled identically until the checker has resolved them. Colouring both +# "indicator" would be wrong half the time. Semantic classification is the job of +# the checker-backed hover service, which has the types to do it honestly. + +_KEYWORDS = frozenset( + { + "strategy", + "tier", + "every", + "if", + "else", + "and", + "or", + "not", + "agent", + "param", + "input", + "output", + "let", + "risk", + "signature", + "route", + "when", + "otherwise", + "escalate", + "range", + "role", + "true", + "false", + } +) _ACTIONS = frozenset({"buy", "sell", "execute", "pause", "observe"}) _TYPE_KINDS = { "INTERVAL": KIND_INTERVAL, "INT": KIND_NUMBER, "FLOAT": KIND_NUMBER, + "STRING": KIND_STRING, "OP": KIND_OPERATOR, + "ASSIGN": KIND_OPERATOR, + "COLON": KIND_PUNCTUATION, + "DOT": KIND_PUNCTUATION, "LBRACE": KIND_PUNCTUATION, "RBRACE": KIND_PUNCTUATION, "LPAREN": KIND_PUNCTUATION, "RPAREN": KIND_PUNCTUATION, + "LBRACKET": KIND_PUNCTUATION, + "RBRACKET": KIND_PUNCTUATION, "COMMA": KIND_PUNCTUATION, } @@ -51,12 +92,19 @@ class SemanticToken: def _classify(token_type: str, value: str) -> str: if token_type == "IDENT": - if value in _KEYWORDS: - return KIND_KEYWORD + # Action names win over keywords where the two overlap: `execute` is a + # keyword inside a `route` block but an action everywhere else, and + # colouring the far more common form correctly is the better trade. if value in _ACTIONS: return KIND_ACTION + if value in _KEYWORDS: + return KIND_KEYWORD return KIND_IDENTIFIER - return _TYPE_KINDS[token_type] + # An unmapped token type would be a lexer/highlighter mismatch. Falling back + # to `identifier` keeps the documented "never raises" contract, which the + # Aether Code /tokens endpoint depends on, instead of turning a new token + # type into a 500. + return _TYPE_KINDS.get(token_type, KIND_IDENTIFIER) def _offset_of(source: str, line: int, column: int) -> int: diff --git a/nano/cli/__init__.py b/nano/cli/__init__.py new file mode 100644 index 0000000..3d5a365 --- /dev/null +++ b/nano/cli/__init__.py @@ -0,0 +1,26 @@ +"""The `nano` developer workflow. + +`check` and `compile` answer "is this correct, and what is the artifact", +`replay` answers "what would it have proposed", and `visualize` answers "what does +the graph look like". Together they are the difference between a language +specification and something you can work in. + +Installed as the `nano` console script; also runnable as `python -m nano.cli`. +""" + +from .commands import EXIT_DIAGNOSTICS, EXIT_IO, EXIT_OK, EXIT_USAGE, Console +from .main import build_parser, main +from .render import FORMATS, graph_document, render + +__all__ = [ + "Console", + "EXIT_DIAGNOSTICS", + "EXIT_IO", + "EXIT_OK", + "EXIT_USAGE", + "FORMATS", + "build_parser", + "graph_document", + "main", + "render", +] diff --git a/nano/cli/__main__.py b/nano/cli/__main__.py new file mode 100644 index 0000000..dcd38a5 --- /dev/null +++ b/nano/cli/__main__.py @@ -0,0 +1,5 @@ +"""Runs the CLI as `python -m nano.cli`, for checkouts without the console script.""" + +from .main import main + +raise SystemExit(main()) diff --git a/nano/cli/commands.py b/nano/cli/commands.py new file mode 100644 index 0000000..ffb2dba --- /dev/null +++ b/nano/cli/commands.py @@ -0,0 +1,382 @@ +"""Command implementations for the `nano` CLI. + +Each command is a function of parsed arguments returning an exit code, writing +through an injected stream. That shape keeps argument parsing (``main.py``) +separate from the work, and lets the tests exercise every command without +spawning a subprocess. + +Exit codes are part of the interface, because CI reads them: + +| Code | Meaning | +|---|---| +| 0 | success | +| 1 | the source or the run was rejected — diagnostics printed | +| 2 | the command was used wrongly (argparse's own code) | +| 3 | an input could not be read | + +`compile` and `check` run the same pipeline and differ only in what they emit, so +`nano check` passing means `nano compile` will not fail on types. Splitting them +gives a pre-commit hook something fast and silent to call. + +Diagnostics print as `file:line:col: error: message`, the form editors and CI +annotators already parse — the positions come from the compiler unchanged. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, List, Optional, Sequence, TextIO + +from ..compiler import ( + NanoCompileError, + check_source, + compile_module, + compile_to_dict, + required_ir_version, +) +from ..data import FeedError, load_frame, parse_date +from ..indicators.registry import INDICATORS, names as indicator_names +from ..ir.schema import SUPPORTED_IR_VERSIONS +from ..runtime.vm import run_module +from ..types.env import KIND_FEED, KIND_INPUT, KIND_LET, KIND_PARAM +from .render import FORMATS, render, summarise_run + +EXIT_OK = 0 +EXIT_DIAGNOSTICS = 1 +EXIT_USAGE = 2 +EXIT_IO = 3 + + +@dataclass(frozen=True) +class Console: + """Where a command writes. Injected so tests need no subprocess.""" + + out: TextIO + err: TextIO + + def say(self, message: str = "") -> None: + print(message, file=self.out) + + def warn(self, message: str) -> None: + print(message, file=self.err) + + +def _read_source(path: Path, console: Console) -> Optional[str]: + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + console.warn(f"error: cannot read {path}: {exc}") + return None + + +def _report_compile_error(path: Path, error: NanoCompileError, console: Console) -> int: + console.warn(f"{path}:{error.line}:{error.column}: error: {error.message}") + return EXIT_DIAGNOSTICS + + +# --------------------------------------------------------------------------- +# nano check +# --------------------------------------------------------------------------- + + +def command_check(args: Any, console: Console) -> int: + """Type-check one or more files. Silent on success, like a linter.""" + failed = 0 + for path in args.files: + source = _read_source(path, console) + if source is None: + return EXIT_IO + try: + program = check_source(source) + except NanoCompileError as error: + _report_compile_error(path, error, console) + failed += 1 + continue + if args.verbose: + console.say( + f"{path}: ok — tier {program.tier}, " + f"warmup {program.warmup} bar(s), " + f"effects {', '.join(program.effects)}, " + f"ir {required_ir_version(program)}" + ) + if failed: + console.warn(f"{failed} of {len(args.files)} file(s) failed") + return EXIT_DIAGNOSTICS + return EXIT_OK + + +# --------------------------------------------------------------------------- +# nano compile +# --------------------------------------------------------------------------- + + +def _emit_types(program, console: Console) -> None: + """Print the resolved type of every declared name, plus warm-up.""" + console.say(f"strategy {program.strategy.name} (tier {program.tier})") + for kind, heading in ( + (KIND_PARAM, "params"), + (KIND_INPUT, "inputs"), + (KIND_LET, "derived"), + (KIND_FEED, "feed signals"), + ): + symbols = program.of_kind(kind) + if not symbols: + continue + console.say(f" {heading}:") + for symbol in symbols: + suffix = f" (warmup {symbol.lookback})" if symbol.lookback else "" + console.say(f" {symbol.describe()}{suffix}") + console.say(f" effects: {', '.join(program.effects)}") + console.say(f" warmup: {program.warmup} bar(s)") + + +def command_compile(args: Any, console: Console) -> int: + """Validate a strategy and emit its execution plan.""" + source = _read_source(args.file, console) + if source is None: + return EXIT_IO + + try: + if args.emit == "types": + _emit_types(check_source(source), console) + return EXIT_OK + if args.emit == "plan": + console.say(render(compile_module(source), "ascii")) + return EXIT_OK + document = compile_to_dict(source, ir_version=args.ir_version) + except NanoCompileError as error: + return _report_compile_error(args.file, error, console) + except ValueError as error: + # IRValidationError / IRVersionError: the program is fine, but the shape + # that was asked for cannot hold it. + console.warn(f"{args.file}: error: {error}") + return EXIT_DIAGNOSTICS + + rendered = json.dumps(document, indent=2) + if args.output is None: + console.say(rendered) + return EXIT_OK + + try: + args.output.write_text(rendered + "\n", encoding="utf-8") + except OSError as exc: + console.warn(f"error: cannot write {args.output}: {exc}") + return EXIT_IO + console.warn( + f"{args.file} -> {args.output} " + f"(nanoIrVersion {document['nanoIrVersion']}, {len(document['nodes'])} nodes)" + ) + return EXIT_OK + + +# --------------------------------------------------------------------------- +# nano replay +# --------------------------------------------------------------------------- + + +def _missing_signals(module, available: Sequence[str]) -> List[str]: + """Names the module reads that the data file does not provide.""" + have = set(available) + needed = [i.name for i in module.inputs] + list(module.signals) + return [name for name in needed if name not in have] + + +def _print_text_report( + module, loaded, result, console: Console, *, verified: bool +) -> None: + filtered = ( + f" ({loaded.rows_filtered} row(s) filtered)" if loaded.rows_filtered else "" + ) + console.say(f"strategy {module.name} ({module.content_hash()[:23]}...)") + console.say(f" bars {len(loaded.frame.timestamps)}{filtered}") + console.say(f" warmup {module.warmup} declared bar(s)") + console.say( + " result " + + summarise_run(result.intents, result.escalations, result.warmup_bars_skipped) + ) + if verified: + console.say(" replay deterministic (verified over two runs)") + + if result.intents: + console.say("") + console.say(" intents:") + for intent in result.intents: + detail = intent.to_dict() + asset = f" {detail['asset']}" if "asset" in detail else "" + confidence = f" @{detail['confidence']}" if "confidence" in detail else "" + console.say( + f" {detail['timestamp']} {detail['intent']}{asset}{confidence}" + ) + + if result.escalations: + console.say("") + console.say(" escalations:") + for escalation in result.escalations: + console.say( + f" {escalation.timestamp} -> {escalation.target}" + f" ({escalation.reason})" + ) + + +def command_replay(args: Any, console: Console) -> int: + """Run a strategy against recorded data and report what it proposed.""" + source = _read_source(args.file, console) + if source is None: + return EXIT_IO + + try: + module = compile_module(source) + except NanoCompileError as error: + return _report_compile_error(args.file, error, console) + + try: + on_date = parse_date(args.date) if args.date else None + loaded = load_frame(args.data, on_date=on_date) + except FeedError as exc: + console.warn(f"error: {exc}") + return EXIT_IO + + if not loaded.frame.timestamps: + console.warn( + "error: no rows to replay" + + (f" for {args.date}" if args.date else "") + + f" ({loaded.rows_read} row(s) read, " + f"{loaded.rows_filtered} filtered out)" + ) + return EXIT_IO + + missing = _missing_signals(module, loaded.signal_names) + if missing: + console.warn( + f"error: {args.data} does not supply {', '.join(missing)} " + f"(it has: {', '.join(loaded.signal_names)})" + ) + return EXIT_IO + + try: + result = run_module(module, loaded.frame) + except Exception as exc: # noqa: BLE001 - reported as a diagnostic, not a crash + console.warn(f"error: replay failed: {exc}") + return EXIT_DIAGNOSTICS + + if args.verify: + # Same module, same frame, twice. A divergence means something in the + # chain is not a pure function of its inputs, which invalidates every + # number the run produced -- so it fails rather than warns. + again = run_module(module, loaded.frame) + if again.to_dict() != result.to_dict(): + console.warn( + "error: replay is not deterministic — two identical runs produced " + "different results" + ) + return EXIT_DIAGNOSTICS + + if args.report == "json": + console.say( + json.dumps( + { + "strategy": module.name, + "moduleHash": module.content_hash(), + "sourceHash": module.source_hash, + "bars": len(loaded.frame.timestamps), + "rowsRead": loaded.rows_read, + "rowsFiltered": loaded.rows_filtered, + "warmupDeclared": module.warmup, + "replayVerified": bool(args.verify), + **result.to_dict(), + }, + indent=2, + ) + ) + return EXIT_OK + + _print_text_report(module, loaded, result, console, verified=bool(args.verify)) + return EXIT_OK + + +# --------------------------------------------------------------------------- +# nano visualize +# --------------------------------------------------------------------------- + + +def command_visualize(args: Any, console: Console) -> int: + """Render a strategy's execution graph.""" + source = _read_source(args.file, console) + if source is None: + return EXIT_IO + try: + module = compile_module(source) + except NanoCompileError as error: + return _report_compile_error(args.file, error, console) + + rendered = render(module, args.format) + if args.output is None: + console.say(rendered) + return EXIT_OK + try: + args.output.write_text(rendered + "\n", encoding="utf-8") + except OSError as exc: + console.warn(f"error: cannot write {args.output}: {exc}") + return EXIT_IO + console.warn(f"{args.file} -> {args.output} ({args.format})") + return EXIT_OK + + +# --------------------------------------------------------------------------- +# nano indicators / nano version +# --------------------------------------------------------------------------- + + +def command_indicators(args: Any, console: Console) -> int: + """List the indicators a strategy may compute, or describe one.""" + if args.name: + spec = INDICATORS.get(args.name) + if spec is None: + console.warn( + f"error: unknown indicator {args.name!r} " + "(try `nano indicators` for the full list)" + ) + return EXIT_DIAGNOSTICS + console.say(spec.signature_text()) + console.say(f" {spec.doc}") + if spec.period_indices: + positions = ", ".join(str(i + 1) for i in spec.period_indices) + console.say( + f" period argument(s) at position {positions} must be " + "compile-time constants" + ) + return EXIT_OK + + for name in indicator_names(): + console.say(INDICATORS[name].signature_text()) + return EXIT_OK + + +def command_version(args: Any, console: Console) -> int: + """Print component versions — useful when a host reports a mismatch.""" + from .. import __version__ + from ..ir.module import COMPILER_NAME, COMPILER_VERSION + + console.say(f"nano {__version__}") + console.say(f" compiler {COMPILER_NAME} {COMPILER_VERSION}") + console.say(f" ir versions {', '.join(SUPPORTED_IR_VERSIONS)}") + console.say(f" indicators {len(INDICATORS)}") + return EXIT_OK + + +__all__ = [ + "Console", + "EXIT_DIAGNOSTICS", + "EXIT_IO", + "EXIT_OK", + "EXIT_USAGE", + "FORMATS", + "command_check", + "command_compile", + "command_indicators", + "command_replay", + "command_version", + "command_visualize", +] diff --git a/nano/cli/main.py b/nano/cli/main.py new file mode 100644 index 0000000..7289c8b --- /dev/null +++ b/nano/cli/main.py @@ -0,0 +1,183 @@ +"""The `nano` command-line entry point. + +Argument parsing lives here and the work lives in ``commands.py``, so a command +can be called directly in a test with a captured ``Console`` instead of a +subprocess. + +The command set is deliberately small and answers the questions a developer +actually asks while writing a strategy: + + nano check strategy.nano does this compile? + nano compile strategy.nano -o ir.json what is the artifact? + nano compile strategy.nano --emit types what did the types resolve to? + nano replay strategy.nano --data bars.csv --date 2026-01-15 + what would it have proposed? + nano visualize strategy.nano what does the graph look like? + nano indicators [NAME] what can I compute, and how long is + its warm-up? + +Nothing here acts on a market. `replay` reads recorded data and prints proposed +intents; there is no `nano trade`, and there will not be one, because emitting an +intent and acting on it are different jobs owned by different systems. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import List, Optional, Sequence + +from ..ir.schema import SUPPORTED_IR_VERSIONS +from .commands import ( + EXIT_OK, + Console, + command_check, + command_compile, + command_indicators, + command_replay, + command_version, + command_visualize, +) +from .render import FORMATS + +_EPILOG = """\ +exit codes: + 0 success + 1 the source or the run was rejected (diagnostics printed to stderr) + 2 the command was used wrongly + 3 an input could not be read + +examples: + nano check src/*.nano + nano compile strategy.nano -o strategy_ir.json + nano compile strategy.nano --emit types + nano replay strategy.nano --data bars.csv --date 2026-01-15 --verify + nano visualize strategy.nano --format mermaid +""" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="nano", + description=( + "Compile, check, replay, and visualise Nano strategies. " + "Nano proposes intents; it never places an order." + ), + epilog=_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subcommands = parser.add_subparsers(dest="command", metavar="COMMAND") + + check = subcommands.add_parser( + "check", help="type-check strategies; silent on success" + ) + check.add_argument("files", nargs="+", type=Path, metavar="FILE") + check.add_argument( + "-v", + "--verbose", + action="store_true", + help="report tier, warm-up, and effects for each file", + ) + check.set_defaults(handler=command_check) + + compile_command = subcommands.add_parser( + "compile", help="validate a strategy and emit its execution plan" + ) + compile_command.add_argument("file", type=Path, metavar="FILE") + compile_command.add_argument( + "-o", "--output", type=Path, help="write IR here instead of stdout" + ) + compile_command.add_argument( + "--emit", + choices=("ir", "types", "plan"), + default="ir", + help="ir: the IR document (default); types: resolved types and warm-up; " + "plan: the execution graph as a tree", + ) + compile_command.add_argument( + "--ir-version", + choices=SUPPORTED_IR_VERSIONS, + default=None, + help="force an IR version. By default the compiler emits the lowest version " + "that can express the program, so v0.1.0-era strategies stay byte-identical", + ) + compile_command.set_defaults(handler=command_compile) + + replay = subcommands.add_parser( + "replay", help="run a strategy against recorded data" + ) + replay.add_argument("file", type=Path, metavar="FILE") + replay.add_argument( + "--data", + type=Path, + required=True, + metavar="PATH", + help="recorded bars as .csv or .json", + ) + replay.add_argument( + "--date", metavar="YYYY-MM-DD", help="replay only this UTC calendar date" + ) + replay.add_argument( + "--report", + choices=("text", "json"), + default="text", + help="text for a human summary (default), json for the full audit log", + ) + replay.add_argument( + "--verify", + action="store_true", + help="run twice and fail if the two runs differ", + ) + replay.set_defaults(handler=command_replay) + + visualize = subcommands.add_parser( + "visualize", help="render the strategy's execution graph" + ) + visualize.add_argument("file", type=Path, metavar="FILE") + visualize.add_argument( + "-f", + "--format", + choices=FORMATS, + default="ascii", + help="ascii (default), mermaid, dot, or json", + ) + visualize.add_argument( + "-o", "--output", type=Path, help="write here instead of stdout" + ) + visualize.set_defaults(handler=command_visualize) + + indicators = subcommands.add_parser( + "indicators", help="list computable indicators, or describe one" + ) + indicators.add_argument("name", nargs="?", metavar="NAME") + indicators.set_defaults(handler=command_indicators) + + version = subcommands.add_parser("version", help="print component versions") + version.set_defaults(handler=command_version) + + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + """Run the CLI. Returns an exit code rather than calling sys.exit.""" + parser = build_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + + if getattr(args, "handler", None) is None: + # No subcommand: print help and succeed. A bare `nano` is someone finding + # their way around, not an error. + parser.print_help() + return EXIT_OK + + console = Console(out=sys.stdout, err=sys.stderr) + return args.handler(args, console) + + +def run(argv: Optional[List[str]] = None) -> None: # pragma: no cover - thin shim + """Entry point for `python -m nano.cli`.""" + raise SystemExit(main(argv)) + + +if __name__ == "__main__": # pragma: no cover + run() diff --git a/nano/cli/render.py b/nano/cli/render.py new file mode 100644 index 0000000..c5726c7 --- /dev/null +++ b/nano/cli/render.py @@ -0,0 +1,302 @@ +"""Renderers for `nano visualize`. + +Four formats, because the audiences differ and no single one serves all of them: + +* **ascii** — a terminal tree. What you want mid-edit, with no other tooling. +* **mermaid** — pastes into a Markdown file, a PR description, or Aether Code's + IR panel and renders as a diagram. +* **dot** — Graphviz, for when the graph is large enough that layout matters. +* **json** — node and edge lists, for a host that draws its own. + +All four are pure functions of a ``NanoModule`` and total: any module the loader +accepted renders, and none can fail on a valid graph. A visualiser that threw on +an unfamiliar opcode would be worse than useless, since an unfamiliar opcode is +exactly when you reach for a picture — so unknown ops render by name rather than +being special-cased. + +The labels deliberately surface what a reviewer needs in order to judge a +strategy: warm-up length, the effect manifest, which signals come from the host +versus which Nano computes, and where a decision escalates instead of executing. +""" + +from __future__ import annotations + +import json +from typing import Dict, List, Sequence, Set + +from ..ir.module import IRNode, NanoModule + +FORMATS = ("ascii", "mermaid", "dot", "json") + +# Human labels for the opcodes whose bare names read poorly in a diagram. +_OP_LABELS = { + "input.ref": "input", + "param.ref": "param", + "feed.signal": "feed", + "builtin.confidence": "confidence", + "series.index": "offset", + "record.field": "field", + "intent.emit": "intent", + "llmre.escalate": "escalate", + "ai.signature": "signature", + "ai.infer": "infer", + "risk.limits": "risk", + "arith.add": "+", + "arith.sub": "-", + "arith.mul": "*", + "arith.div": "/", + "arith.mod": "%", + "arith.neg": "negate", + "compare.lt": "<", + "compare.le": "<=", + "compare.gt": ">", + "compare.ge": ">=", + "compare.eq": "==", + "compare.ne": "!=", + "logic.and": "and", + "logic.or": "or", + "logic.not": "not", +} + +_NAMED_OPS = ("input.ref", "param.ref", "feed.signal", "let", "agent") +_EFFECT_OPS = ("intent.emit", "llmre.escalate") + + +def node_label(node: IRNode) -> str: + """A short, human-readable label for one node.""" + base = _OP_LABELS.get(node.op, node.op) + attrs = node.attrs + + if node.op in _NAMED_OPS: + role = attrs.get("role") + return f"{base} {attrs.get('name', '?')}" + (f" [{role}]" if role else "") + if node.op == "schedule": + return f"every {attrs.get('interval', '?')}" + if node.op == "const": + return f"{attrs.get('value')!r}" + if node.op == "series.index": + return f"[{attrs.get('offset')}]" + if node.op == "record.field": + return f".{attrs.get('field')}" + if node.op == "indicator": + periods = attrs.get("periods") or [] + suffix = f"({', '.join(str(p) for p in periods)})" if periods else "" + return f"{attrs.get('name')}{suffix}" + if node.op == "intent.emit": + parts = [str(attrs.get("action"))] + if attrs.get("asset"): + parts.append(str(attrs["asset"])) + if attrs.get("confidence") is not None: + parts.append(f"@{attrs['confidence']}") + return " ".join(parts) + if node.op == "llmre.escalate": + return f"escalate {attrs.get('target')}" + if node.op == "ai.signature": + return f"signature {attrs.get('name')}" + if node.op == "ai.infer": + return f"infer {attrs.get('signature')}" + if node.op == "route": + return f"route {attrs.get('name')} -> {attrs.get('execute')}" + if node.op == "risk.limits": + limits = attrs.get("limits") or {} + return "risk " + ", ".join(f"{k}={v}" for k, v in sorted(limits.items())) + return base + + +def _header(module: NanoModule) -> List[str]: + lines = [ + f"strategy {module.name}", + f" tier {module.tier}", + f" effects {', '.join(module.effects)}", + ] + if module.warmup: + lines.append(f" warmup {module.warmup} bars") + if module.params: + lines.append( + " params " + + ", ".join(f"{p.name}: {p.type} = {p.value!r}" for p in module.params) + ) + if module.inputs: + lines.append( + " inputs " + ", ".join(f"{i.name}: {i.type}" for i in module.inputs) + ) + if module.signals: + lines.append(f" signals {', '.join(module.signals)} (host-supplied)") + return lines + + +def _draw( + index: Dict[str, IRNode], + node_id: str, + lines: List[str], + *, + prefix: str, + is_last: bool, + seen: Set[str], +) -> None: + node = index[node_id] + connector = "`- " if is_last else "|- " + label = node_label(node) + + if node_id in seen: + # A shared node -- one `feed.signal` read by three conditions -- is drawn + # once and referenced afterwards, so the tree shows reuse instead of + # implying three separate data sources. + lines.append(f"{prefix}{connector}{label} (shared, see {node_id})") + return + seen.add(node_id) + + lines.append(f"{prefix}{connector}{label} [{node_id}]") + child_prefix = prefix + (" " if is_last else "| ") + children = list(node.inputs) + for position, child in enumerate(children): + _draw( + index, + child, + lines, + prefix=child_prefix, + is_last=position == len(children) - 1, + seen=seen, + ) + + +def to_ascii(module: NanoModule) -> str: + """A terminal tree rooted at each entry point. + + Data flows bottom-up in the DAG, so each entry is drawn downward through its + operands — the direction a reader traces when asking "what decided this?". + """ + index = module.index() + lines = _header(module) + + if not module.entries: + lines.append(" (no rules — nothing is scheduled to run)") + + for entry in module.entries: + lines.append("") + _draw(index, entry, lines, prefix=" ", is_last=True, seen=set()) + return "\n".join(lines) + + +def _mermaid_shape(node: IRNode, label: str) -> str: + """Pick a node shape that signals what kind of thing this is.""" + safe = label.replace('"', "'") + if node.op in _EFFECT_OPS: + return f'{node.id}{{{{"{safe}"}}}}' # hexagon: an effect leaves the graph + if node.op in ("rule", "route", "schedule"): + return f'{node.id}[["{safe}"]]' # subroutine: control flow + if node.op in ("input.ref", "feed.signal", "param.ref", "const"): + return f'{node.id}(["{safe}"])' # stadium: a data source + return f'{node.id}["{safe}"]' + + +def to_mermaid(module: NanoModule) -> str: + """A Mermaid flowchart. Renders in Markdown, a PR, or Aether Code.""" + lines = [ + f"%% {module.name} - tier {module.tier}, warmup {module.warmup} bars", + "flowchart BT", + ] + for node in module.nodes: + lines.append(f" {_mermaid_shape(node, node_label(node))}") + for node in module.nodes: + for child in node.inputs: + lines.append(f" {child} --> {node.id}") + for entry in module.entries: + # Highlight entry points: execution starts there, and in a bottom-up graph + # they are otherwise indistinguishable from any other sink. + lines.append(f" style {entry} stroke-width:3px") + return "\n".join(lines) + + +def to_dot(module: NanoModule) -> str: + """Graphviz DOT, for graphs large enough that layout matters.""" + lines = [ + f'digraph "{module.name}" {{', + " rankdir=BT;", + ' node [shape=box, fontname="monospace"];', + f' label="{module.name} - tier {module.tier}, warmup {module.warmup} bars";', + " labelloc=t;", + ] + for node in module.nodes: + label = node_label(node).replace('"', '\\"') + shape = "hexagon" if node.op in _EFFECT_OPS else "box" + peripheries = 2 if node.id in module.entries else 1 + lines.append( + f' "{node.id}" [label="{label}", shape={shape}, ' + f"peripheries={peripheries}];" + ) + for node in module.nodes: + for child in node.inputs: + lines.append(f' "{child}" -> "{node.id}";') + lines.append("}") + return "\n".join(lines) + + +def graph_document(module: NanoModule) -> dict: + """Node and edge lists, for a host that draws its own diagram. + + This is what Aether Code's IR visualiser consumes: labels are pre-computed so + the front end need not re-implement opcode formatting, while raw `op` and + `attrs` travel along so it can style or filter as it likes. + """ + nodes = [ + { + "id": node.id, + "op": node.op, + "label": node_label(node), + "type": node.type, + "attrs": dict(node.attrs), + "isEntry": node.id in module.entries, + } + for node in module.nodes + ] + edges = [ + {"from": child, "to": node.id, "port": position} + for node in module.nodes + for position, child in enumerate(node.inputs) + ] + return { + "name": module.name, + "tier": module.tier, + "effects": list(module.effects), + "warmup": module.warmup, + "signals": list(module.signals), + "params": [p.to_dict() for p in module.params], + "inputs": [i.to_dict() for i in module.inputs], + "moduleHash": module.content_hash(), + "nodes": nodes, + "edges": edges, + "entries": list(module.entries), + } + + +def to_graph_json(module: NanoModule) -> str: + return json.dumps(graph_document(module), indent=2) + + +_RENDERERS = { + "ascii": to_ascii, + "mermaid": to_mermaid, + "dot": to_dot, + "json": to_graph_json, +} + + +def render(module: NanoModule, fmt: str) -> str: + """Render `module` in one of ``FORMATS``.""" + renderer = _RENDERERS.get(fmt) + if renderer is None: + raise ValueError( + f"Unknown format {fmt!r} (expected one of {', '.join(FORMATS)})" + ) + return renderer(module) + + +def summarise_run( + intents: Sequence[object], escalations: Sequence[object], skipped: int +) -> str: + """One-line outcome summary, shared by `replay`'s text report.""" + return ( + f"{len(intents)} intent(s), {len(escalations)} escalation(s), " + f"{skipped} unwarmed bar(s) skipped" + ) diff --git a/nano/compiler/__init__.py b/nano/compiler/__init__.py index 843742f..eaa5f8c 100644 --- a/nano/compiler/__init__.py +++ b/nano/compiler/__init__.py @@ -1,22 +1,61 @@ -"""`.nano` -> Nano IR compiler (Milestone 4). +"""`.nano` -> Nano IR compiler. -The compiler is the only path from surface syntax to IR, and this module is -its stable public API: tokenize, parse, compile_source, compile_to_dict, and -NanoSyntaxError. Downstream tooling (editor services, the LSP) consumes exactly -this surface — keep it stable. +The compiler is the only path from surface syntax to IR, and this module is its +stable public API. Downstream tooling — the editor services in +``nano/aethercode/``, the CLI, and Aether Code's language-service endpoints — +consumes exactly this surface, so names here are kept and added to, never +repurposed. + +Which entry point to use: + +| Want | Call | +|---|---| +| The IR document a host should store | ``compile_to_dict`` | +| An executable module (either version) | ``compile_module`` | +| A baseline ``StrategyGraph`` | ``compile_source`` | +| Types and diagnostics without IR | ``check_source`` | + +``compile_to_dict`` emits the lowest IR version that can express the program, so a +v0.1.0-era strategy still compiles to byte-identical v0.1.0 output. Pass +``ir_version`` to force a shape. + +Catch ``NanoCompileError`` to handle any compile failure; ``NanoSyntaxError``, +``NanoTypeError``, and ``LookaheadError`` narrow it, and every one of them carries +an exact 1-based line and column. """ -from .codegen import compile_source, compile_to_dict -from .errors import NanoSyntaxError -from .lexer import tokenize +from .codegen import ( + EFFECTS_V0_1_0, + IRVersionError, + ast_to_dict, + check_source, + compile_module, + compile_source, + compile_to_dict, + required_ir_version, + source_hash, +) +from .errors import LookaheadError, NanoCompileError, NanoSyntaxError, NanoTypeError +from .lexer import decode_string, tokenize from .parser import parse from .tokens import Token __all__ = [ + "EFFECTS_V0_1_0", + "IRVersionError", + "LookaheadError", + "NanoCompileError", "NanoSyntaxError", + "NanoTypeError", "Token", + "ast_to_dict", + "check_source", + "compile_module", "compile_source", "compile_to_dict", + "decode_string", "parse", + "required_ir_version", + "source_hash", "tokenize", ] diff --git a/nano/compiler/ast.py b/nano/compiler/ast.py new file mode 100644 index 0000000..455c7cd --- /dev/null +++ b/nano/compiler/ast.py @@ -0,0 +1,351 @@ +"""The `.nano` abstract syntax tree (v1.0). + +Every node is immutable and carries the 1-based line/column of its first token, +because the type checker reports positions the parser never sees again — an AST +node without a position is a diagnostic that has to guess, and Nano does not +guess (see ``nano/compiler/errors.py``). + +The tree is a strict superset of the v0.1.0 shape. `StrategyAst.schedules` and +`ScheduleAst.rules` are tuples now that a strategy may declare several of each, +with `.schedule` / `.rule` kept as first-element accessors so v0.1-era callers +read unchanged. `ConditionAst` survives as the *legacy lowering view* — a +flattened `SIGNAL op NUMBER` triple recovered from an expression tree by +``nano/compiler/legacy.py`` — not as something the parser produces directly. + +Base classes contribute `line`/`column` as their first fields, so every +construction site passes operands by keyword. That is deliberate: it keeps +argument order out of the correctness budget. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Tuple, Union + +Number = Union[int, float] +Literal = Union[int, float, str, bool] + + +# --------------------------------------------------------------------------- +# Expressions +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Expr: + """Base for every expression. Subclasses add operands, never mutability.""" + + line: int + column: int + + +@dataclass(frozen=True) +class NumberLit(Expr): + value: Number + + +@dataclass(frozen=True) +class StringLit(Expr): + value: str + + +@dataclass(frozen=True) +class BoolLit(Expr): + value: bool + + +@dataclass(frozen=True) +class DurationLit(Expr): + """An interval literal used as a value, e.g. `5m`.""" + + text: str + + +@dataclass(frozen=True) +class Name(Expr): + """A reference to a param, input, let-binding, feed signal, or builtin.""" + + name: str + + +@dataclass(frozen=True) +class Index(Expr): + """`target[offset]` — read `target` as it stood `offset` bars ago. + + Offsets count *backwards*. There is no syntax for a forward offset, and a + provably negative one is a compile error, which is how look-ahead becomes + unrepresentable rather than merely discouraged. + """ + + target: Expr + offset: Expr + + +@dataclass(frozen=True) +class Member(Expr): + """`target.field` — reads one declared output of a signature result.""" + + target: Expr + field_name: str + + +@dataclass(frozen=True) +class Call(Expr): + callee: str + args: Tuple[Expr, ...] + + +@dataclass(frozen=True) +class Unary(Expr): + op: str # "-" | "not" + operand: Expr + + +@dataclass(frozen=True) +class Binary(Expr): + op: str # + - * / % < <= > >= == != and or + left: Expr + right: Expr + + +# --------------------------------------------------------------------------- +# Statements +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Stmt: + line: int + column: int + + +@dataclass(frozen=True) +class ActionAst(Stmt): + """A proposal: `buy(BTC, 0.9)`, `pause()`. + + `action` is already the IR spelling (BUY/SELL/EXECUTE/PAUSE/OBSERVE); the + parser maps surface names so no downstream stage repeats that table. + """ + + action: str + asset: Optional[str] = None + confidence: Optional[Number] = None + + +@dataclass(frozen=True) +class EscalateStmt(Stmt): + """`escalate "research-agent"` — hand this decision back to a reasoning model. + + Requires `llmre.escalate` in the effect manifest. Escalating is an effect, + not a control-flow keyword: it is recorded, rate-limitable, and gated + exactly like proposing an intent. + + `is_name` records which spelling was used, and it changes what gets checked. + `escalate Research` names a declared `agent`, so a misspelling is a compile + error. `escalate "research-agent"` is an opaque host-resolved target the + compiler cannot verify. Both are useful; collapsing them would mean either + losing the check or banning the string form. + """ + + target: str + is_name: bool = False + + +@dataclass(frozen=True) +class IfStmt(Stmt): + when: Expr + then: Tuple[Stmt, ...] + otherwise: Tuple[Stmt, ...] = () + + +# --------------------------------------------------------------------------- +# Declarations +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ParamAst: + """A compile-time-tunable constant — the surface an optimizer sweeps.""" + + name: str + declared_type: Optional[str] + value: Literal + line: int + column: int + + +@dataclass(frozen=True) +class InputAst: + """A declared feed input. The host supplies it; Nano never fetches it.""" + + name: str + declared_type: str + line: int + column: int + + +@dataclass(frozen=True) +class LetAst: + """A derived binding, typically a computed series: `let ema20 = EMA(price, 20)`.""" + + name: str + declared_type: Optional[str] + expr: Expr + line: int + column: int + + +@dataclass(frozen=True) +class RiskLimitAst: + name: str + value: Number + line: int + column: int + + +@dataclass(frozen=True) +class RiskAst: + limits: Tuple[RiskLimitAst, ...] + line: int + column: int + + +@dataclass(frozen=True) +class SigFieldAst: + """One field of a signature, with an optional `range [lo, hi]` refinement.""" + + name: str + declared_type: str + low: Optional[Number] + high: Optional[Number] + line: int + column: int + + +@dataclass(frozen=True) +class SignatureAst: + """A typed reasoning-call contract. Raw prompt strings do not exist in Nano.""" + + name: str + inputs: Tuple[SigFieldAst, ...] + outputs: Tuple[SigFieldAst, ...] + line: int + column: int + + +@dataclass(frozen=True) +class RouteAst: + """Confidence routing: run the compiled path, or escalate when unsure.""" + + name: str + execute: str + when: Expr + otherwise: EscalateStmt + line: int + column: int + + +@dataclass(frozen=True) +class AgentAst: + """A named behavior block. `role` classifies it for escalation routing.""" + + name: str + role: Optional[str] = None + line: int = 0 + column: int = 0 + + +@dataclass(frozen=True) +class RuleAst: + when: Expr + then: Tuple[Stmt, ...] + otherwise: Tuple[Stmt, ...] + line: int + column: int + + +@dataclass(frozen=True) +class ScheduleAst: + interval: str + rules: Tuple[RuleAst, ...] = () + line: int = 0 + column: int = 0 + + @property + def rule(self) -> Optional[RuleAst]: + """The first rule, or None — the v0.1.0 single-rule accessor.""" + return self.rules[0] if self.rules else None + + +@dataclass(frozen=True) +class StrategyAst: + name: str + tier: str = "nano" + params: Tuple[ParamAst, ...] = () + inputs: Tuple[InputAst, ...] = () + lets: Tuple[LetAst, ...] = () + risk: Optional[RiskAst] = None + signatures: Tuple[SignatureAst, ...] = () + routes: Tuple[RouteAst, ...] = () + agents: Tuple[AgentAst, ...] = () + schedules: Tuple[ScheduleAst, ...] = () + line: int = 1 + column: int = 1 + + @property + def schedule(self) -> Optional[ScheduleAst]: + """The first schedule block, or None — the v0.1.0 single-schedule accessor.""" + return self.schedules[0] if self.schedules else None + + +# --------------------------------------------------------------------------- +# Legacy lowering view +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ConditionAst: + """A `SIGNAL op NUMBER` comparison against a host-supplied feed signal. + + Recovered from an expression tree, not parsed. This is the only condition + shape v0.1.0 IR can represent, so `nano/compiler/legacy.py` tries to + flatten every rule into these before the compiler picks an IR version. + """ + + signal: str + operator: str + value: Number + + +__all__ = [ + "ActionAst", + "AgentAst", + "Binary", + "BoolLit", + "Call", + "ConditionAst", + "DurationLit", + "EscalateStmt", + "Expr", + "IfStmt", + "Index", + "InputAst", + "LetAst", + "Literal", + "Member", + "Name", + "Number", + "NumberLit", + "ParamAst", + "RiskAst", + "RiskLimitAst", + "RouteAst", + "RuleAst", + "ScheduleAst", + "SigFieldAst", + "SignatureAst", + "Stmt", + "StrategyAst", + "StringLit", + "Unary", +] diff --git a/nano/compiler/codegen.py b/nano/compiler/codegen.py index f541144..d07a358 100644 --- a/nano/compiler/codegen.py +++ b/nano/compiler/codegen.py @@ -1,64 +1,583 @@ """AST -> Nano IR code generation. -Invariant: output is canonical. The generated dict has exactly the shape and -node ordering StrategyGraph.to_dict produces (schedule, then conditions, -intents, and agents in source order), so compile -> from_dict -> to_dict is a -fixed point and compiled IR is byte-diffable against the hand-written example -corpus. Codegen only transforms a parser-validated AST; it performs no -position-carrying validation of its own — the final StrategyGraph.from_dict -pass re-checks the IR contract itself. +Two output shapes, one decision rule: **the compiler emits the lowest IR version +that can express the program** (see ``legacy.py`` for why). Baseline output is +byte-identical to what v0.1.0 produced, so the example corpus, the strategy +library, and Aether Code's pinned snapshot are untouched by v1.0. Anything +reaching past baseline becomes a v1.0 DAG. + +Invariant: output is canonical. Baseline emits the historical node ordering +(schedule, conditions, intents, agents) so compiled IR stays byte-diffable +against the hand-written corpus. v1.0 emits operands before the nodes that +consume them, giving a topologically ordered DAG with fixed key order — two +compiles of the same source produce the same bytes, and `moduleHash` is +meaningful. + +Codegen validates nothing about names or types; ``nano/types/checker.py`` has +already proven the program meaningful, and the final ``from_dict`` pass re-checks +the IR contract itself. """ from __future__ import annotations +import hashlib +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple, Union + from ..ir.graph import StrategyGraph -from ..ir.schema import NANO_IR_VERSION -from .parser import StrategyAst, parse +from ..ir.module import ( + ARITHMETIC_OPS, + COMPARISON_OPS, + COMPILER_NAME, + COMPILER_VERSION, + DETERMINISM_CONTRACT, + InputDecl, + IRNode, + NanoModule, + ParamDecl, +) +from ..ir.schema import ( + NANO_IR_VERSION_1_0, + NANO_IR_VERSION_BASELINE, + SUPPORTED_IR_VERSIONS, + IRValidationError, +) +from ..types.checker import ( + ResolvedFeed, + ResolvedIndicator, + ResolvedInfer, + TypedProgram, + check, +) +from ..types.env import KIND_FEED, KIND_INPUT, KIND_LET, KIND_PARAM +from .ast import ( + ActionAst, + Binary, + BoolLit, + Call, + DurationLit, + EscalateStmt, + Expr, + IfStmt, + Index, + Member, + Name, + NumberLit, + RuleAst, + Stmt, + StringLit, + StrategyAst, + Unary, +) +from .legacy import baseline_shape +from .parser import parse -# Every v0.1.0 strategy declares exactly this manifest, in this order. +# Every v0.1.0 strategy declares exactly this manifest, in this order. It is a +# constant rather than derived because the baseline contract fixed it: a v0.1.0 +# document says `["intent.emit", "log.append"]` whether or not the strategy +# happens to emit an intent. v1.0 derives its manifest from what the program +# actually does (see TypedProgram.effects). EFFECTS_V0_1_0 = ("intent.emit", "log.append") -def ast_to_dict(strategy: StrategyAst) -> dict: - """Lower a parsed strategy to a canonical Nano IR dict.""" - nodes: list[dict] = [] - if strategy.schedule is not None: - nodes.append({"type": "Schedule", "interval": strategy.schedule.interval}) - rule = strategy.schedule.rule - if rule is not None: - for condition in rule.conditions: - nodes.append( - { - "type": "Condition", - "signal": condition.signal, - "operator": condition.operator, - "value": condition.value, - } - ) - for action in rule.actions: - intent: dict = {"type": "Intent", "action": action.action} - if action.asset is not None: - intent["asset"] = action.asset - if action.confidence is not None: - intent["confidence"] = action.confidence - nodes.append(intent) - for agent in strategy.agents: +class IRVersionError(IRValidationError): + """The requested IR version cannot represent this program.""" + + +# --------------------------------------------------------------------------- +# version inference +# --------------------------------------------------------------------------- + + +def required_ir_version(program: TypedProgram) -> str: + """The lowest IR version that can express `program`.""" + return ( + NANO_IR_VERSION_BASELINE + if baseline_shape(program) is not None + else NANO_IR_VERSION_1_0 + ) + + +# --------------------------------------------------------------------------- +# baseline emission +# --------------------------------------------------------------------------- + + +def _baseline_dict(program: TypedProgram) -> dict: + shape = baseline_shape(program) + if shape is None: + raise IRVersionError( + f"Strategy {program.strategy.name!r} uses v1.0 constructs that " + f"{NANO_IR_VERSION_BASELINE} IR cannot represent; compile it as " + f"{NANO_IR_VERSION_1_0}" + ) + + nodes: List[dict] = [] + if shape.interval is not None: + nodes.append({"type": "Schedule", "interval": shape.interval}) + for condition in shape.conditions: + nodes.append( + { + "type": "Condition", + "signal": condition.signal, + "operator": condition.operator, + "value": condition.value, + } + ) + for action in shape.actions: + intent: dict = {"type": "Intent", "action": action.action} + if action.asset is not None: + intent["asset"] = action.asset + if action.confidence is not None: + intent["confidence"] = action.confidence + nodes.append(intent) + for agent in shape.agents: nodes.append({"type": "Agent", "name": agent.name}) return { "type": "Strategy", - "nanoIrVersion": NANO_IR_VERSION, - "name": strategy.name, + "nanoIrVersion": NANO_IR_VERSION_BASELINE, + "name": program.strategy.name, "effects": list(EFFECTS_V0_1_0), "nodes": nodes, } +# --------------------------------------------------------------------------- +# v1.0 lowering +# --------------------------------------------------------------------------- + + +@dataclass +class _Lowerer: + """Builds a topologically ordered DAG from a type-checked program.""" + + program: TypedProgram + + def __post_init__(self) -> None: + self.nodes: List[IRNode] = [] + self._counter = 0 + # Named references are shared: `RSI` read from three conditions is one + # `feed.signal` node, so the graph shows one data source rather than + # three, and a consumer counting inputs counts them once. + self._named: Dict[str, str] = {} + + # -- emission ---------------------------------------------------------- + + def _emit( + self, + op: str, + *, + inputs: Tuple[str, ...] = (), + attrs: Optional[dict] = None, + type_: Optional[str] = None, + ) -> str: + self._counter += 1 + node_id = f"n{self._counter}" + self.nodes.append( + IRNode( + id=node_id, + op=op, + inputs=inputs, + attrs=attrs or {}, + type=type_, + ) + ) + return node_id + + def _type_of(self, expr: Expr) -> Optional[str]: + resolved = self.program.type_of(expr) + return str(resolved) if resolved is not None else None + + # -- entry ------------------------------------------------------------- + + def run(self, *, source_hash: Optional[str]) -> NanoModule: + strategy = self.program.strategy + + for signature in strategy.signatures: + self._emit( + "ai.signature", + attrs={ + "name": signature.name, + "inputs": [_field_dict(f) for f in signature.inputs], + "outputs": [_field_dict(f) for f in signature.outputs], + }, + ) + + for binding in strategy.lets: + value = self._lower_expr(binding.expr) + node_id = self._emit( + "let", + inputs=(value,), + attrs={"name": binding.name}, + type_=self._type_of(binding.expr), + ) + self._named[f"{KIND_LET}:{binding.name}"] = node_id + + if strategy.risk is not None: + self._emit( + "risk.limits", + attrs={ + "limits": { + limit.name: limit.value for limit in strategy.risk.limits + } + }, + ) + + for agent in strategy.agents: + attrs: dict = {"name": agent.name} + if agent.role is not None: + attrs["role"] = agent.role + self._emit("agent", attrs=attrs) + + entries: List[str] = [] + for schedule in strategy.schedules: + schedule_id = self._emit( + "schedule", attrs={"interval": schedule.interval} + ) + for rule in schedule.rules: + entries.append(self._lower_rule(rule, schedule_id)) + + for route in strategy.routes: + condition = self._lower_expr(route.when) + escalation = self._lower_escalate(route.otherwise) + entries.append( + self._emit( + "route", + inputs=(condition, escalation), + attrs={"name": route.name, "execute": route.execute}, + ) + ) + + provenance: dict = { + "compiler": {"name": COMPILER_NAME, "version": COMPILER_VERSION} + } + if source_hash is not None: + provenance["sourceHash"] = source_hash + + return NanoModule( + name=strategy.name, + tier=self.program.tier, + effects=self.program.effects, + nodes=tuple(self.nodes), + entries=tuple(entries), + params=tuple( + ParamDecl( + name=symbol.name, + type=str(symbol.type), + value=symbol.const_value, + ) + for symbol in self.program.of_kind(KIND_PARAM) + ), + inputs=tuple( + InputDecl(name=symbol.name, type=str(symbol.type)) + for symbol in self.program.of_kind(KIND_INPUT) + ), + signals=self.program.feed_signals, + warmup=self.program.warmup, + determinism=dict(DETERMINISM_CONTRACT), + provenance=provenance, + ) + + # -- statements -------------------------------------------------------- + + def _lower_rule(self, rule: RuleAst, schedule_id: str) -> str: + condition = self._lower_expr(rule.when) + then_block = self._lower_block(rule.then, schedule_id) + inputs = [schedule_id, condition, then_block] + if rule.otherwise: + inputs.append(self._lower_block(rule.otherwise, schedule_id)) + return self._emit("rule", inputs=tuple(inputs)) + + def _lower_block(self, statements: Tuple[Stmt, ...], schedule_id: str) -> str: + return self._emit( + "block", + inputs=tuple(self._lower_statement(s, schedule_id) for s in statements), + ) + + def _lower_statement(self, statement: Stmt, schedule_id: str) -> str: + if isinstance(statement, ActionAst): + attrs: dict = {"action": statement.action} + if statement.asset is not None: + attrs["asset"] = statement.asset + if statement.confidence is not None: + attrs["confidence"] = statement.confidence + return self._emit("intent.emit", attrs=attrs) + + if isinstance(statement, EscalateStmt): + return self._lower_escalate(statement) + + if isinstance(statement, IfStmt): + # A nested `if` is just a rule under the same schedule. Reusing the + # opcode keeps one evaluation path for guarded work at any depth. + condition = self._lower_expr(statement.when) + then_block = self._lower_block(statement.then, schedule_id) + inputs = [schedule_id, condition, then_block] + if statement.otherwise: + inputs.append(self._lower_block(statement.otherwise, schedule_id)) + return self._emit("rule", inputs=tuple(inputs)) + + raise IRValidationError( + f"Cannot lower statement {type(statement).__name__}" + ) + + def _lower_escalate(self, statement: EscalateStmt) -> str: + return self._emit( + "llmre.escalate", + attrs={"target": statement.target, "isAgent": statement.is_name}, + ) + + # -- expressions ------------------------------------------------------- + + def _lower_expr(self, expr: Expr) -> str: + if isinstance(expr, NumberLit): + return self._emit( + "const", attrs={"value": expr.value}, type_=self._type_of(expr) + ) + if isinstance(expr, StringLit): + return self._emit( + "const", attrs={"value": expr.value}, type_=self._type_of(expr) + ) + if isinstance(expr, BoolLit): + return self._emit( + "const", attrs={"value": expr.value}, type_=self._type_of(expr) + ) + if isinstance(expr, DurationLit): + return self._emit( + "const", attrs={"value": expr.text}, type_=self._type_of(expr) + ) + if isinstance(expr, Name): + return self._lower_name(expr) + if isinstance(expr, Index): + return self._lower_index(expr) + if isinstance(expr, Member): + return self._emit( + "record.field", + inputs=(self._lower_expr(expr.target),), + attrs={"field": expr.field_name}, + type_=self._type_of(expr), + ) + if isinstance(expr, Call): + return self._lower_call(expr) + if isinstance(expr, Unary): + op = "logic.not" if expr.op == "not" else "arith.neg" + return self._emit( + op, + inputs=(self._lower_expr(expr.operand),), + type_=self._type_of(expr), + ) + if isinstance(expr, Binary): + return self._lower_binary(expr) + raise IRValidationError(f"Cannot lower expression {type(expr).__name__}") + + def _lower_name(self, expr: Name) -> str: + symbol = self.program.symbols.get(expr.name) + if symbol is None: # pragma: no cover - the checker declares every name + raise IRValidationError(f"Unresolved name {expr.name!r}") + + if symbol.kind == KIND_LET: + return self._named[f"{KIND_LET}:{expr.name}"] + + op = { + KIND_PARAM: "param.ref", + KIND_INPUT: "input.ref", + KIND_FEED: "feed.signal", + }.get(symbol.kind, "builtin.confidence") + + key = f"{symbol.kind}:{expr.name}" + existing = self._named.get(key) + if existing is not None: + return existing + + attrs = {} if op == "builtin.confidence" else {"name": expr.name} + node_id = self._emit(op, attrs=attrs, type_=str(symbol.type)) + self._named[key] = node_id + return node_id + + def _lower_index(self, expr: Index) -> str: + # The checker already proved the offset folds to a non-negative integer, + # so it becomes a compile-time attribute rather than a live operand — + # there is nothing left to evaluate, and nothing a runtime could subvert. + offset = _fold_offset(expr, self.program) + return self._emit( + "series.index", + inputs=(self._lower_expr(expr.target),), + attrs={"offset": offset}, + type_=self._type_of(expr), + ) + + def _lower_call(self, expr: Call) -> str: + resolution = self.program.resolution_of(expr) + + if isinstance(resolution, ResolvedFeed): + key = f"{KIND_FEED}:{resolution.signal}" + existing = self._named.get(key) + if existing is not None: + return existing + node_id = self._emit( + "feed.signal", + attrs={"name": resolution.signal}, + type_=self._type_of(expr), + ) + self._named[key] = node_id + return node_id + + if isinstance(resolution, ResolvedInfer): + arguments = tuple(self._lower_expr(a) for a in expr.args[1:]) + return self._emit( + "ai.infer", + inputs=arguments, + attrs={"signature": resolution.signature}, + type_=self._type_of(expr), + ) + + if isinstance(resolution, ResolvedIndicator): + # Period arguments are compile-time constants and live in `periods`; + # only the value operands become graph inputs, so the DAG shows real + # data flow instead of literals masquerading as dependencies. + period_positions = set(resolution.spec.period_indices) + operands = tuple( + self._lower_expr(argument) + for index, argument in enumerate(expr.args) + if index not in period_positions + ) + return self._emit( + "indicator", + inputs=operands, + attrs={ + "name": resolution.name, + "periods": list(resolution.periods), + "lookback": resolution.lookback, + "lifted": resolution.lifted, + }, + type_=self._type_of(expr), + ) + + raise IRValidationError( # pragma: no cover - checker resolves every call + f"Unresolved call {expr.callee!r}" + ) + + def _lower_binary(self, expr: Binary) -> str: + if expr.op in COMPARISON_OPS: + op = COMPARISON_OPS[expr.op] + elif expr.op in ARITHMETIC_OPS: + op = ARITHMETIC_OPS[expr.op] + else: + op = f"logic.{expr.op}" + return self._emit( + op, + inputs=(self._lower_expr(expr.left), self._lower_expr(expr.right)), + type_=self._type_of(expr), + ) + + +def _field_dict(field) -> dict: + """Serialise one signature field, omitting an absent range.""" + out: dict = {"name": field.name, "type": field.declared_type} + if field.low is not None and field.high is not None: + out["range"] = [field.low, field.high] + return out + + +def _fold_offset(expr: Index, program: TypedProgram) -> int: + """Re-fold a validated series offset. + + Imports locally to keep the module-level dependency graph one-directional: + `types` already imports `compiler.ast`, and a top-level import back into + `types.lookahead` from here would make the two packages mutually dependent at + import time. + """ + from ..types.lookahead import fold_int + + folded = fold_int(expr.offset, _scope_of(program)) + if folded is None: # pragma: no cover - resolve_offset already proved this + raise IRValidationError("Series offset did not fold to a constant") + return folded + + +def _scope_of(program: TypedProgram): + """A Scope view over the checked program, for constant re-folding.""" + from ..types.env import Scope + + scope = Scope() + for symbol in program.symbols.values(): + scope.declare(symbol) + return scope + + +# --------------------------------------------------------------------------- +# public API +# --------------------------------------------------------------------------- + + +def source_hash(source: str) -> str: + """Content address of `.nano` source text. + + Separate from `moduleHash` on purpose: this changes when a comment changes, + that one does not, and being able to tell those apart is the difference + between "the file was edited" and "the behavior was edited". + """ + return "sha256:" + hashlib.sha256(source.encode("utf-8")).hexdigest() + + +def check_source(source: str) -> TypedProgram: + """Parse and type-check `.nano` source. Raises on the first fault.""" + return check(parse(source)) + + +def ast_to_dict(strategy: StrategyAst) -> dict: + """Lower a parsed strategy to a canonical baseline IR dict. + + Retained for callers that specifically want baseline output. Raises + ``IRVersionError`` if the strategy needs v1.0. + """ + return _baseline_dict(check(strategy)) + + +def compile_module(source: str) -> NanoModule: + """Compile `.nano` source to a validated v1.0 module. + + Always succeeds for any program the checker accepts, including baseline ones + — the module is the executable form, so this is what runtimes and the CLI + use. + """ + program = check_source(source) + module = _Lowerer(program).run(source_hash=source_hash(source)) + # Round-trip through the loader so the emitted document is held to exactly + # the contract an external one would be. A compiler that trusts its own + # output is a compiler whose invariants drift. + return NanoModule.from_dict(module.to_dict()) + + def compile_source(source: str) -> StrategyGraph: - """Compile `.nano` source to a validated StrategyGraph.""" - return StrategyGraph.from_dict(ast_to_dict(parse(source))) + """Compile `.nano` source to a validated baseline StrategyGraph. + + Raises ``IRVersionError`` when the program uses v1.0 constructs; use + ``compile_module`` for those, or ``compile_to_dict`` to get whichever + document shape fits. + """ + return StrategyGraph.from_dict(_baseline_dict(check_source(source))) + + +def compile_to_dict(source: str, *, ir_version: Optional[str] = None) -> dict: + """Compile `.nano` source to the canonical Nano IR dict. + + With no `ir_version`, emits the lowest version that can express the program: + baseline output stays byte-identical to v0.1.0 for programs that fit, and + everything else becomes `1.0.0`. Pass `ir_version` to force one shape. + """ + if ir_version is not None and ir_version not in SUPPORTED_IR_VERSIONS: + raise IRVersionError( + f"Unsupported IR version {ir_version!r} " + f"(expected one of {', '.join(SUPPORTED_IR_VERSIONS)})" + ) + + program = check_source(source) + target = ir_version or required_ir_version(program) + + if target == NANO_IR_VERSION_BASELINE: + return _baseline_dict(program) + return _Lowerer(program).run(source_hash=source_hash(source)).to_dict() -def compile_to_dict(source: str) -> dict: - """Compile `.nano` source to the canonical Nano IR dict.""" - return compile_source(source).to_dict() +CompiledIR = Union[StrategyGraph, NanoModule] diff --git a/nano/compiler/errors.py b/nano/compiler/errors.py index 9fdc205..47a0433 100644 --- a/nano/compiler/errors.py +++ b/nano/compiler/errors.py @@ -1,14 +1,29 @@ -"""Compiler error type. +"""Compiler error types. Invariant: every compile-time failure carries a 1-based line and column that points at real source text. Downstream tooling (editor services, the LSP) relies on these positions being exact — no error is ever raised without them. + +Taxonomy (v1.0): + + NanoCompileError any compile-time failure -- catch this + └── NanoSyntaxError lexing / parsing + └── NanoTypeError semantic analysis (types, arity, unknown names) + └── LookaheadError a read of future data + +``NanoTypeError`` deliberately subclasses ``NanoSyntaxError`` rather than +sitting beside it. Hosts embedding the compiler (Aether Code's +``/agent/code/nano/compile``) already branch on ``NanoSyntaxError`` to surface +exact positions and fall back to a positionless line-1 message for anything +else; inheriting keeps type and look-ahead errors in the precise branch with no +host change. New code should catch ``NanoCompileError`` — it is the honest name +for "the compiler rejected this source". """ from __future__ import annotations -class NanoSyntaxError(ValueError): +class NanoCompileError(ValueError): """`.nano` source failed to compile. Carries .line, .column, .message.""" def __init__(self, message: str, line: int, column: int) -> None: @@ -16,3 +31,21 @@ def __init__(self, message: str, line: int, column: int) -> None: self.message = message self.line = line self.column = column + + +class NanoSyntaxError(NanoCompileError): + """Source is not well-formed: the lexer or parser rejected it.""" + + +class NanoTypeError(NanoSyntaxError): + """Source parses but violates the type system or a semantic rule.""" + + +class LookaheadError(NanoTypeError): + """Source attempts to read data from the future. + + Raised for any series access whose offset is not provably non-negative. + Look-ahead is a correctness bug that silently inflates every backtest, so + Nano makes it unrepresentable rather than merely detectable — see + ``nano/types/lookahead.py``. + """ diff --git a/nano/compiler/legacy.py b/nano/compiler/legacy.py new file mode 100644 index 0000000..0d60407 --- /dev/null +++ b/nano/compiler/legacy.py @@ -0,0 +1,144 @@ +"""Baseline (v0.1.0) shape recovery — how the compiler picks an IR version. + +Nano's central claim is that the artifact that backtested is the artifact that +trades is the artifact the audit replays. Byte-stability of compiled output is +therefore not a nicety; it is the claim. Twenty-one `.nano`/`_ir.json` pairs in +`nano/examples/` and `nano/library/` assert it, and Aether Code ships a vendored +snapshot pinned to `0.1.0`. + +So v1.0 does not renumber everything. **The compiler emits the lowest IR version +that can express the program.** This module answers the only question that +decision needs: *is this program expressible in baseline IR?* + +Baseline IR is a flat list — one schedule, an `and`-chain of +`SIGNAL NUMBER` conditions, some intents, some agents. It has no way to +represent a second rule, an `else` branch, a computed series, a typed param, a +risk limit, or a reasoning call. A program that stays inside those limits +compiles to the same bytes it did before v1.0 existed. A program that reaches +past them becomes `1.0.0`, where those things have a representation. + +`nano compile --ir-version` overrides the inference when a host wants one shape +regardless of content. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from ..types.checker import ResolvedFeed, TypedProgram +from .ast import ( + ActionAst, + AgentAst, + Binary, + Call, + ConditionAst, + Expr, + Name, + NumberLit, +) + +_COMPARISON_OPS = frozenset({"<", "<=", ">", ">=", "==", "!="}) + + +@dataclass(frozen=True) +class BaselineShape: + """A program flattened into the baseline document's four node kinds.""" + + interval: Optional[str] + conditions: Tuple[ConditionAst, ...] + actions: Tuple[ActionAst, ...] + agents: Tuple[AgentAst, ...] + + +def _flatten_and_chain(expr: Expr) -> List[Expr]: + """Split a left-associated `and` chain into its leaves, in source order.""" + if isinstance(expr, Binary) and expr.op == "and": + return _flatten_and_chain(expr.left) + _flatten_and_chain(expr.right) + return [expr] + + +def _as_feed_signal(expr: Expr, program: TypedProgram) -> Optional[str]: + """The signal name, if `expr` reads a host-supplied feed signal. + + Both spellings count: a bare `RSI`, and the documented `RSI(14)` form whose + integer argument is a contract with the feed rather than a computation. + Membership is decided by what the checker resolved, not by re-guessing here. + """ + if not isinstance(expr, (Name, Call)): + return None + resolution = program.resolution_of(expr) + if isinstance(resolution, ResolvedFeed): + return resolution.signal + return None + + +def _as_condition(expr: Expr, program: TypedProgram) -> Optional[ConditionAst]: + """Flatten one comparison into a baseline `Condition`, or give up.""" + if not isinstance(expr, Binary) or expr.op not in _COMPARISON_OPS: + return None + signal = _as_feed_signal(expr.left, program) + if signal is None or not isinstance(expr.right, NumberLit): + return None + return ConditionAst(signal=signal, operator=expr.op, value=expr.right.value) + + +def baseline_shape(program: TypedProgram) -> Optional[BaselineShape]: + """Flatten `program` into baseline shape, or return None if it does not fit. + + Every rejection below names a construct baseline IR has no node for. None of + them is a shortcoming of this function — adding a case would mean inventing a + representation, and a `0.1.0` document that a `0.1.0` reader cannot + understand is worse than a `1.0.0` one. + """ + strategy = program.strategy + + if strategy.tier != "nano": + return None + if strategy.params or strategy.inputs or strategy.lets: + return None + if strategy.risk is not None or strategy.signatures or strategy.routes: + return None + if any(agent.role is not None for agent in strategy.agents): + return None + if len(strategy.schedules) > 1: + return None + + interval: Optional[str] = None + conditions: Tuple[ConditionAst, ...] = () + actions: Tuple[ActionAst, ...] = () + + if strategy.schedules: + schedule = strategy.schedules[0] + interval = schedule.interval + if len(schedule.rules) > 1: + return None + + if schedule.rules: + rule = schedule.rules[0] + if rule.otherwise: + return None + if not all(isinstance(s, ActionAst) for s in rule.then): + return None + + flattened: List[ConditionAst] = [] + for leaf in _flatten_and_chain(rule.when): + condition = _as_condition(leaf, program) + if condition is None: + return None + flattened.append(condition) + if not flattened: + # An unconditional rule -- `every 5m { observe() }` -- is a v1.0 + # addition. Baseline required an `if`, and its interpreter only + # emits intents when at least one condition held. + return None + + conditions = tuple(flattened) + actions = tuple(s for s in rule.then if isinstance(s, ActionAst)) + + return BaselineShape( + interval=interval, + conditions=conditions, + actions=actions, + agents=strategy.agents, + ) diff --git a/nano/compiler/lexer.py b/nano/compiler/lexer.py index fbf3989..9bc36b5 100644 --- a/nano/compiler/lexer.py +++ b/nano/compiler/lexer.py @@ -18,11 +18,25 @@ "}": "RBRACE", "(": "LPAREN", ")": "RPAREN", + "[": "LBRACKET", + "]": "RBRACKET", ",": "COMMA", + ":": "COLON", + ".": "DOT", } +# Arithmetic operators. They share the OP token type with comparisons; the +# parser separates them by precedence, and editor tooling colours them alike. +_ARITHMETIC = frozenset("+-*/%") + _INTERVAL_UNITS = frozenset("smhd") +# Escapes recognised inside a string literal. Deliberately short: strings in +# Nano name escalation targets and signature inputs — they are not a text +# processing facility, and every extra escape is one more thing two runtimes can +# disagree about. +_STRING_ESCAPES = {'"': '"', "\\": "\\", "n": "\n", "t": "\t"} + def _is_ident_start(ch: str) -> bool: return "a" <= ch <= "z" or "A" <= ch <= "Z" or ch == "_" @@ -87,12 +101,61 @@ def tokenize(source: str) -> Tuple[Token, ...]: i += 2 column += 2 continue + if ch == "=": + # A lone `=` is a real token: it binds params and lets. Writing + # it where a comparison belongs is a parser-level diagnostic, so + # the error can name what was expected instead of just refusing + # the character. + tokens.append(Token("ASSIGN", "=", start_line, start_column)) + i += 1 + column += 1 + continue raise NanoSyntaxError( f"Unknown operator {ch!r} (expected one of <, <=, >, >=, ==, !=)", start_line, start_column, ) + if ch in _ARITHMETIC: + tokens.append(Token("OP", ch, start_line, start_column)) + i += 1 + column += 1 + continue + + if ch == '"': + # The token carries the *raw* span, quotes and escapes included, so + # `len(value)` stays the source width editor highlighting needs. + # Escapes are validated here and decoded by `decode_string`. + j = i + 1 + while j < n and source[j] != '"': + if source[j] == "\n": + raise NanoSyntaxError( + "Unterminated string literal (a string cannot span lines)", + start_line, + start_column, + ) + if source[j] == "\\": + if j + 1 >= n or source[j + 1] not in _STRING_ESCAPES: + raise NanoSyntaxError( + f"Unknown string escape {source[j : j + 2]!r} (expected " + f"one of {', '.join(sorted(_STRING_ESCAPES))})", + start_line, + start_column + (j - i), + ) + j += 2 + continue + j += 1 + if j >= n: + raise NanoSyntaxError( + 'Unterminated string literal (missing closing \'"\')', + start_line, + start_column, + ) + tokens.append(Token("STRING", source[i : j + 1], start_line, start_column)) + column += (j + 1) - i + i = j + 1 + continue + if _is_ident_start(ch): j = i while j < n and _is_ident_char(source[j]): @@ -151,3 +214,22 @@ def tokenize(source: str) -> Tuple[Token, ...]: tokens.append(Token("EOF", "", line, column)) return tuple(tokens) + + +def decode_string(raw: str) -> str: + """Decode a STRING token's raw span into its value. + + The lexer already rejected unknown escapes and unterminated literals, so + this only has to undo what it validated. + """ + body = raw[1:-1] + out: list[str] = [] + index = 0 + while index < len(body): + if body[index] == "\\" and index + 1 < len(body): + out.append(_STRING_ESCAPES[body[index + 1]]) + index += 2 + continue + out.append(body[index]) + index += 1 + return "".join(out) diff --git a/nano/compiler/parser.py b/nano/compiler/parser.py index b59b4ca..527e973 100644 --- a/nano/compiler/parser.py +++ b/nano/compiler/parser.py @@ -1,92 +1,141 @@ -"""Recursive-descent parser for `.nano` (v0.1.0 grammar) plus its AST. - -Invariant: a StrategyAst that leaves this module already satisfies every -semantic rule of the locked grammar — at most one schedule block, at most one -rule per schedule, known action names only, confidence within [0, 1] — because -this is the last place a violation can still be reported with the exact 1-based -line/column of the offending token. The AST is immutable; codegen only -transforms, it never re-validates positions. - -Grammar (locked, v0.1.0): - - program := "strategy" IDENT "{" item* "}" - item := schedule-block | agent-decl - agent-decl := "agent" IDENT - schedule := "every" INTERVAL "{" rule? "}" - rule := "if" condition ("and" condition)* "{" action+ "}" - condition := IDENT [ "(" INT ")" ] OP NUMBER - action := "buy" "(" IDENT ["," NUMBER] ")" - | "sell" "(" IDENT ["," NUMBER] ")" - | "execute" "(" ")" | "pause" "(" ")" | "observe" "(" ")" +"""Recursive-descent parser for `.nano` (v1.0 grammar). + +Invariant: a StrategyAst that leaves this module is well-formed — every block is +closed, every action is known, every literal is in range — because this is the +last place a violation can still be reported with the exact 1-based line/column +of the offending token. Meaning is somebody else's job: names, types, arity, and +look-ahead belong to ``nano/types/checker.py``, which runs next. + +The v1.0 grammar is a superset of v0.1.0. Everything that compiled before parses +to the same AST, so the example corpus and strategy library are untouched; what +v1.0 adds is declarations, real expressions, and control flow. + +Grammar (locked, v1.0): + + program = [ tierDecl ] , strategy ; + tierDecl = "tier" , ( "nano" | "nano+" | "nano++" ) ; + strategy = "strategy" , IDENT , "{" , member* , "}" ; + + member = paramDecl | inputDecl | letDecl | riskBlock + | signatureDecl | routeBlock | agentDecl | scheduleBlock ; + paramDecl = "param" , IDENT , [ ":" , type ] , "=" , literal ; + inputDecl = "input" , IDENT , ":" , type ; + letDecl = "let" , IDENT , [ ":" , type ] , "=" , expr ; + riskBlock = "risk" , "{" , { IDENT , number } , "}" ; + signatureDecl = "signature" , IDENT , "{" , sigField+ , "}" ; + sigField = ( "input" | "output" ) , IDENT , ":" , type , + [ "range" , "[" , number , "," , number , "]" ] ; + routeBlock = "route" , IDENT , "{" , "execute" , IDENT , [ "when" ] , expr , + "otherwise" , "{" , escalate , "}" , "}" ; + agentDecl = "agent" , IDENT , [ "{" , "role" , IDENT , "}" ] ; + scheduleBlock = "every" , INTERVAL , "{" , statement* , "}" ; + + statement = ifStmt | escalate | action ; + ifStmt = "if" , expr , "{" , statement* , "}" , + [ "else" , "{" , statement* , "}" ] ; + escalate = "escalate" , ( STRING | IDENT ) ; + action = ( "buy" | "sell" ) , "(" , IDENT , [ "," , number ] , ")" + | ( "execute" | "pause" | "observe" ) , "(" , ")" ; + + expr = orExpr ; + orExpr = andExpr , { "or" , andExpr } ; + andExpr = notExpr , { "and" , notExpr } ; + notExpr = [ "not" ] , comparison ; + comparison = additive , [ ( "<" | "<=" | ">" | ">=" | "==" | "!=" ) , additive ] ; + additive = multiplicative , { ( "+" | "-" ) , multiplicative } ; + multiplicative = unary , { ( "*" | "/" | "%" ) , unary } ; + unary = [ "-" ] , postfix ; + postfix = primary , { "[" , expr , "]" | "." , IDENT } ; + primary = number | STRING | "true" | "false" | INTERVAL + | IDENT , [ "(" , [ expr , { "," , expr } ] , ")" ] + | "(" , expr , ")" ; + +Comparisons do not chain: `a < b < c` is rejected rather than silently parsed as +`(a < b) < c`, which in a strategy would compare a boolean against a price. """ from __future__ import annotations -from dataclasses import dataclass -from typing import Optional, Tuple, Union - +from typing import List, Optional, Tuple + +from .ast import ( + ActionAst, + AgentAst, + Binary, + BoolLit, + Call, + ConditionAst, + DurationLit, + EscalateStmt, + Expr, + IfStmt, + Index, + InputAst, + LetAst, + Literal, + Member, + Name, + Number, + NumberLit, + ParamAst, + RiskAst, + RiskLimitAst, + RouteAst, + RuleAst, + ScheduleAst, + SigFieldAst, + SignatureAst, + Stmt, + StrategyAst, + StringLit, + Unary, +) from .errors import NanoSyntaxError -from .lexer import tokenize +from .lexer import decode_string, tokenize from .tokens import Token -Number = Union[int, float] - # Surface action name -> IR intent action. buy/sell take (asset[, confidence]); # the rest take no arguments. _ASSET_ACTIONS = {"buy": "BUY", "sell": "SELL"} _NULLARY_ACTIONS = {"execute": "EXECUTE", "pause": "PAUSE", "observe": "OBSERVE"} _ALL_ACTIONS = {**_ASSET_ACTIONS, **_NULLARY_ACTIONS} +_COMPARISON_OPS = frozenset({"<", "<=", ">", ">=", "==", "!="}) +_ADDITIVE_OPS = frozenset({"+", "-"}) +_MULTIPLICATIVE_OPS = frozenset({"*", "/", "%"}) -@dataclass(frozen=True) -class ConditionAst: - signal: str - operator: str - value: Number - - -@dataclass(frozen=True) -class ActionAst: - action: str # IR action: BUY, SELL, EXECUTE, PAUSE, OBSERVE - asset: Optional[str] = None - confidence: Optional[Number] = None - - -@dataclass(frozen=True) -class RuleAst: - conditions: Tuple[ConditionAst, ...] - actions: Tuple[ActionAst, ...] - - -@dataclass(frozen=True) -class ScheduleAst: - interval: str - rule: Optional[RuleAst] - - -@dataclass(frozen=True) -class AgentAst: - name: str - - -@dataclass(frozen=True) -class StrategyAst: - name: str - schedule: Optional[ScheduleAst] - agents: Tuple[AgentAst, ...] # in source order +_MEMBER_KEYWORDS = ( + "param", + "input", + "let", + "risk", + "signature", + "route", + "agent", + "every", +) class _Parser: def __init__(self, tokens: Tuple[Token, ...]) -> None: self._tokens = tokens self._pos = 0 + # Set when a `>=` token had to be split into `>` (closing a type) plus a + # leftover `=`. See _close_type_bracket. + self._pending: Optional[Token] = None # -- token plumbing ---------------------------------------------------- def _peek(self) -> Token: + if self._pending is not None: + return self._pending return self._tokens[self._pos] def _advance(self) -> Token: + if self._pending is not None: + token = self._pending + self._pending = None + return token token = self._tokens[self._pos] if token.type != "EOF": self._pos += 1 @@ -118,6 +167,10 @@ def _at_keyword(self, keyword: str) -> bool: token = self._peek() return token.type == "IDENT" and token.value == keyword + def _at_op(self, *values: str) -> bool: + token = self._peek() + return token.type == "OP" and token.value in values + def _expect_closing_brace(self, block: str) -> None: token = self._peek() if token.type == "EOF": @@ -128,15 +181,27 @@ def _expect_closing_brace(self, block: str) -> None: ) self._advance() - # -- grammar productions ----------------------------------------------- + # -- program ----------------------------------------------------------- def parse_program(self) -> StrategyAst: - self._expect_keyword("strategy") + tier = "nano" + if self._at_keyword("tier"): + self._advance() + tier = self._parse_tier() + + header = self._expect_keyword("strategy") name = self._expect("IDENT", "strategy name").value self._expect("LBRACE", "'{'") - schedule: Optional[ScheduleAst] = None - agents: list[AgentAst] = [] + params: List[ParamAst] = [] + inputs: List[InputAst] = [] + lets: List[LetAst] = [] + signatures: List[SignatureAst] = [] + routes: List[RouteAst] = [] + agents: List[AgentAst] = [] + schedules: List[ScheduleAst] = [] + risk: Optional[RiskAst] = None + while True: token = self._peek() if token.type == "RBRACE": @@ -144,22 +209,33 @@ def parse_program(self) -> StrategyAst: break if token.type == "EOF": raise self._error("Unterminated 'strategy' block (missing '}')", token) - if self._at_keyword("agent"): - self._advance() - agents.append(AgentAst(name=self._expect("IDENT", "agent name").value)) - continue - if self._at_keyword("every"): - if schedule is not None: + + if self._at_keyword("param"): + params.append(self._parse_param()) + elif self._at_keyword("input"): + inputs.append(self._parse_input()) + elif self._at_keyword("let"): + lets.append(self._parse_let()) + elif self._at_keyword("risk"): + if risk is not None: raise self._error( - "At most one schedule block is allowed per strategy", token + "At most one risk block is allowed per strategy", token ) - schedule = self._parse_schedule() - continue - raise self._error( - f"Unexpected token {self._describe(token)} in 'strategy' block " - "(expected 'every' or 'agent')", - token, - ) + risk = self._parse_risk() + elif self._at_keyword("signature"): + signatures.append(self._parse_signature()) + elif self._at_keyword("route"): + routes.append(self._parse_route()) + elif self._at_keyword("agent"): + agents.append(self._parse_agent()) + elif self._at_keyword("every"): + schedules.append(self._parse_schedule()) + else: + raise self._error( + f"Unexpected token {self._describe(token)} in 'strategy' block " + f"(expected one of {', '.join(_MEMBER_KEYWORDS)})", + token, + ) trailing = self._peek() if trailing.type != "EOF": @@ -167,66 +243,321 @@ def parse_program(self) -> StrategyAst: f"Unexpected token {self._describe(trailing)} after strategy block", trailing, ) - return StrategyAst(name=name, schedule=schedule, agents=tuple(agents)) + return StrategyAst( + name=name, + tier=tier, + params=tuple(params), + inputs=tuple(inputs), + lets=tuple(lets), + risk=risk, + signatures=tuple(signatures), + routes=tuple(routes), + agents=tuple(agents), + schedules=tuple(schedules), + line=header.line, + column=header.column, + ) - def _parse_schedule(self) -> ScheduleAst: - self._expect_keyword("every") - interval = self._expect("INTERVAL", "interval (e.g. 5m, 1h)").value - self._expect("LBRACE", "'{'") + def _parse_tier(self) -> str: + """Read `nano`, `nano+`, or `nano++`. - rule: Optional[RuleAst] = None - if self._at_keyword("if"): - rule = self._parse_rule() + The lexer has no idea `nano++` is one word — it sees an identifier and + two `+` operators — so the tier is reassembled here. + """ + base = self._expect("IDENT", "tier name (nano, nano+, or nano++)") + tier = base.value + while self._at_op("+"): + self._advance() + tier += "+" + return tier + + # -- declarations ------------------------------------------------------ + + def _parse_param(self) -> ParamAst: + keyword = self._expect_keyword("param") + name = self._expect("IDENT", "param name").value + declared = self._parse_optional_annotation() + self._expect("ASSIGN", "'=' followed by a default value") + value = self._parse_literal() + return ParamAst( + name=name, + declared_type=declared, + value=value, + line=keyword.line, + column=keyword.column, + ) - token = self._peek() - if token.type == "EOF": - raise self._error("Unterminated 'every' block (missing '}')", token) - if self._at_keyword("if"): - raise self._error( - "At most one rule is allowed per schedule block", token + def _parse_input(self) -> InputAst: + keyword = self._expect_keyword("input") + name = self._expect("IDENT", "input name").value + self._expect("COLON", "':' followed by a type") + declared = self._parse_type() + return InputAst( + name=name, + declared_type=declared, + line=keyword.line, + column=keyword.column, + ) + + def _parse_let(self) -> LetAst: + keyword = self._expect_keyword("let") + name = self._expect("IDENT", "binding name").value + declared = self._parse_optional_annotation() + self._expect("ASSIGN", "'=' followed by an expression") + expr = self._parse_expr() + return LetAst( + name=name, + declared_type=declared, + expr=expr, + line=keyword.line, + column=keyword.column, + ) + + def _parse_risk(self) -> RiskAst: + keyword = self._expect_keyword("risk") + self._expect("LBRACE", "'{'") + limits: List[RiskLimitAst] = [] + while True: + token = self._peek() + if token.type == "RBRACE": + self._advance() + break + if token.type == "EOF": + raise self._error("Unterminated 'risk' block (missing '}')", token) + name_token = self._expect("IDENT", "risk limit name") + value = self._parse_number("risk limit value") + limits.append( + RiskLimitAst( + name=name_token.value, + value=value, + line=name_token.line, + column=name_token.column, + ) ) - if token.type != "RBRACE": + return RiskAst(limits=tuple(limits), line=keyword.line, column=keyword.column) + + def _parse_signature(self) -> SignatureAst: + keyword = self._expect_keyword("signature") + name = self._expect("IDENT", "signature name").value + self._expect("LBRACE", "'{'") + inputs: List[SigFieldAst] = [] + outputs: List[SigFieldAst] = [] + while True: + token = self._peek() + if token.type == "RBRACE": + self._advance() + break + if token.type == "EOF": + raise self._error( + "Unterminated 'signature' block (missing '}')", token + ) + if self._at_keyword("input"): + self._advance() + inputs.append(self._parse_signature_field()) + elif self._at_keyword("output"): + self._advance() + outputs.append(self._parse_signature_field()) + else: + raise self._error( + f"Unexpected token {self._describe(token)} in 'signature' " + "block (expected 'input' or 'output')", + token, + ) + if not outputs: raise self._error( - f"Unexpected token {self._describe(token)} in 'every' block", token + f"Signature {name!r} declares no outputs — a reasoning call with " + "no typed result has nothing a strategy can act on", + keyword, ) - self._advance() - return ScheduleAst(interval=interval, rule=rule) + return SignatureAst( + name=name, + inputs=tuple(inputs), + outputs=tuple(outputs), + line=keyword.line, + column=keyword.column, + ) - def _parse_rule(self) -> RuleAst: - self._expect_keyword("if") - conditions = [self._parse_condition()] - while self._at_keyword("and"): + def _parse_signature_field(self) -> SigFieldAst: + name_token = self._expect("IDENT", "field name") + self._expect("COLON", "':' followed by a type") + declared = self._parse_type() + low: Optional[Number] = None + high: Optional[Number] = None + if self._at_keyword("range"): + self._advance() + self._expect("LBRACKET", "'['") + low = self._parse_number("range lower bound") + self._expect("COMMA", "','") + high = self._parse_number("range upper bound") + self._expect("RBRACKET", "']'") + return SigFieldAst( + name=name_token.value, + declared_type=declared, + low=low, + high=high, + line=name_token.line, + column=name_token.column, + ) + + def _parse_route(self) -> RouteAst: + keyword = self._expect_keyword("route") + name = self._expect("IDENT", "route name").value + self._expect("LBRACE", "'{'") + self._expect_keyword("execute") + execute = self._expect("IDENT", "the name this route executes").value + # `when` is optional: the locked platform spec writes the guard on its own + # line, while spelling it out reads better inline. Both mean the same. + if self._at_keyword("when"): self._advance() - conditions.append(self._parse_condition()) + when = self._parse_expr() + self._expect_keyword("otherwise") + self._expect("LBRACE", "'{'") + otherwise = self._parse_escalate() + self._expect_closing_brace("'otherwise'") + self._expect_closing_brace("'route'") + return RouteAst( + name=name, + execute=execute, + when=when, + otherwise=otherwise, + line=keyword.line, + column=keyword.column, + ) + + def _parse_agent(self) -> AgentAst: + keyword = self._expect_keyword("agent") + name_token = self._expect("IDENT", "agent name") + role: Optional[str] = None + # A brace here can only open an agent body: every other member starts + # with its own keyword, so there is nothing to disambiguate against. + if self._peek().type == "LBRACE": + self._advance() + while True: + token = self._peek() + if token.type == "RBRACE": + self._advance() + break + if token.type == "EOF": + raise self._error("Unterminated 'agent' block (missing '}')", token) + self._expect_keyword("role") + role = self._expect("IDENT", "agent role").value + return AgentAst( + name=name_token.value, + role=role, + line=keyword.line, + column=keyword.column, + ) + + # -- schedules and statements ------------------------------------------ + + def _parse_schedule(self) -> ScheduleAst: + keyword = self._expect_keyword("every") + interval = self._expect("INTERVAL", "interval (e.g. 5m, 1h)").value self._expect("LBRACE", "'{'") + statements = self._parse_block_statements("'every'") + return ScheduleAst( + interval=interval, + rules=self._group_into_rules(statements), + line=keyword.line, + column=keyword.column, + ) - actions = [self._parse_action()] + def _group_into_rules(self, statements: Tuple[Stmt, ...]) -> Tuple[RuleAst, ...]: + """Turn a schedule body into rules, preserving source order. + + An `if` becomes a guarded rule. A run of bare statements becomes one + unconditional rule — `every 5m { observe() }` should mean "observe every + bar", and making the author write `if true` to say that would be noise. + """ + rules: List[RuleAst] = [] + pending: List[Stmt] = [] + + def flush() -> None: + if not pending: + return + first = pending[0] + rules.append( + RuleAst( + when=BoolLit(line=first.line, column=first.column, value=True), + then=tuple(pending), + otherwise=(), + line=first.line, + column=first.column, + ) + ) + pending.clear() + + for statement in statements: + if isinstance(statement, IfStmt): + flush() + rules.append( + RuleAst( + when=statement.when, + then=statement.then, + otherwise=statement.otherwise, + line=statement.line, + column=statement.column, + ) + ) + continue + pending.append(statement) + flush() + return tuple(rules) + + def _parse_block_statements(self, block: str) -> Tuple[Stmt, ...]: + statements: List[Stmt] = [] while True: token = self._peek() - if token.type == "IDENT": - actions.append(self._parse_action()) - continue - self._expect_closing_brace("rule") - break - return RuleAst(conditions=tuple(conditions), actions=tuple(actions)) - - def _parse_condition(self) -> ConditionAst: - signal = self._expect("IDENT", "signal name").value - if self._peek().type == "LPAREN": - # Parenthesized argument, e.g. RSI(14): accepted, dropped from IR. + if token.type == "RBRACE": + self._advance() + return tuple(statements) + if token.type == "EOF": + raise self._error(f"Unterminated {block} block (missing '}}')", token) + statements.append(self._parse_statement()) + + def _parse_statement(self) -> Stmt: + if self._at_keyword("if"): + return self._parse_if() + if self._at_keyword("escalate"): + return self._parse_escalate() + return self._parse_action() + + def _parse_if(self) -> IfStmt: + keyword = self._expect_keyword("if") + when = self._parse_expr() + self._expect("LBRACE", "'{'") + then = self._parse_block_statements("'if'") + otherwise: Tuple[Stmt, ...] = () + if self._at_keyword("else"): self._advance() - self._expect("INT", "integer signal argument") - self._expect("RPAREN", "')'") - operator_token = self._peek() - if operator_token.type != "OP": - raise self._error( - "Expected comparison operator (one of <, <=, >, >=, ==, !=), " - f"got {self._describe(operator_token)}", - operator_token, + self._expect("LBRACE", "'{'") + otherwise = self._parse_block_statements("'else'") + return IfStmt( + line=keyword.line, + column=keyword.column, + when=when, + then=then, + otherwise=otherwise, + ) + + def _parse_escalate(self) -> EscalateStmt: + keyword = self._expect_keyword("escalate") + token = self._peek() + if token.type == "STRING": + self._advance() + return EscalateStmt( + line=keyword.line, + column=keyword.column, + target=decode_string(token.value), + is_name=False, ) - self._advance() - value = self._parse_number("condition value") - return ConditionAst(signal=signal, operator=operator_token.value, value=value) + name = self._expect("IDENT", "an agent name or a quoted target") + return EscalateStmt( + line=keyword.line, + column=keyword.column, + target=name.value, + is_name=True, + ) def _parse_action(self) -> ActionAst: name_token = self._expect("IDENT", "action name") @@ -242,7 +573,9 @@ def _parse_action(self) -> ActionAst: if surface_name in _NULLARY_ACTIONS: self._expect("RPAREN", "')'") - return ActionAst(action=action) + return ActionAst( + line=name_token.line, column=name_token.column, action=action + ) asset = self._expect("IDENT", "asset name").value confidence: Optional[Number] = None @@ -256,21 +589,289 @@ def _parse_action(self) -> ActionAst: confidence_token, ) self._expect("RPAREN", "')'") - return ActionAst(action=action, asset=asset, confidence=confidence) + return ActionAst( + line=name_token.line, + column=name_token.column, + action=action, + asset=asset, + confidence=confidence, + ) - def _parse_number(self, what: str) -> Number: + # -- types ------------------------------------------------------------- + + def _parse_optional_annotation(self) -> Optional[str]: + if self._peek().type != "COLON": + return None + self._advance() + return self._parse_type() + + def _parse_type(self) -> str: + """Parse a type into its canonical spelling, e.g. `series`.""" + head = self._expect("IDENT", "type name") + if not self._at_op("<"): + return head.value + self._advance() + element = self._expect("IDENT", "element type").value + self._close_type_bracket() + return f"{head.value}<{element}>" + + def _close_type_bracket(self) -> None: + """Consume the `>` closing a generic type. + + `series=EMA(...)` lexes the `>=` as one operator, the same way C++ + once mis-lexed `vector>`. Splitting it here — taking the `>` + and leaving an `=` for the caller — means the author does not have to + remember a space before the equals sign. + """ token = self._peek() - if token.type == "INT": + if token.type == "OP" and token.value == ">": self._advance() - return int(token.value) - if token.type == "FLOAT": + return + if token.type == "OP" and token.value == ">=": self._advance() - return float(token.value) + self._pending = Token("ASSIGN", "=", token.line, token.column + 1) + return raise self._error( - f"Expected numeric {what}, got {self._describe(token)}", token + f"Expected '>' to close the type, got {self._describe(token)}", token + ) + + # -- expressions ------------------------------------------------------- + + def _parse_expr(self) -> Expr: + return self._parse_or() + + def _parse_or(self) -> Expr: + left = self._parse_and() + while self._at_keyword("or"): + token = self._advance() + right = self._parse_and() + left = Binary( + line=token.line, column=token.column, op="or", left=left, right=right + ) + return left + + def _parse_and(self) -> Expr: + left = self._parse_not() + while self._at_keyword("and"): + token = self._advance() + right = self._parse_not() + left = Binary( + line=token.line, column=token.column, op="and", left=left, right=right + ) + return left + + def _parse_not(self) -> Expr: + if self._at_keyword("not"): + token = self._advance() + return Unary( + line=token.line, + column=token.column, + op="not", + operand=self._parse_not(), + ) + return self._parse_comparison() + + def _parse_comparison(self) -> Expr: + left = self._parse_additive() + + token = self._peek() + if token.type == "ASSIGN": + # `=` can never legally follow an expression here, and a lone `=` in a + # condition is always a mistyped `==`. Naming the operator that was + # expected beats a downstream "expected '{'" pointing somewhere else. + raise self._error( + "Expected comparison operator (one of <, <=, >, >=, ==, !=), " + f"got {self._describe(token)}", + token, + ) + if not self._at_op(*_COMPARISON_OPS): + return left + + operator = self._advance() + right = self._parse_additive() + result = Binary( + line=operator.line, + column=operator.column, + op=operator.value, + left=left, + right=right, ) + if self._at_op(*_COMPARISON_OPS): + chained = self._peek() + raise self._error( + f"Comparisons do not chain: {chained.value!r} would compare a " + "boolean against a value. Split it with 'and'", + chained, + ) + return result + + def _parse_additive(self) -> Expr: + left = self._parse_multiplicative() + while self._at_op(*_ADDITIVE_OPS): + operator = self._advance() + right = self._parse_multiplicative() + left = Binary( + line=operator.line, + column=operator.column, + op=operator.value, + left=left, + right=right, + ) + return left + + def _parse_multiplicative(self) -> Expr: + left = self._parse_unary() + while self._at_op(*_MULTIPLICATIVE_OPS): + operator = self._advance() + right = self._parse_unary() + left = Binary( + line=operator.line, + column=operator.column, + op=operator.value, + left=left, + right=right, + ) + return left + + def _parse_unary(self) -> Expr: + if self._at_op("-"): + token = self._advance() + return Unary( + line=token.line, + column=token.column, + op="-", + operand=self._parse_unary(), + ) + return self._parse_postfix() + + def _parse_postfix(self) -> Expr: + expr = self._parse_primary() + while True: + token = self._peek() + if token.type == "LBRACKET": + self._advance() + offset = self._parse_expr() + self._expect("RBRACKET", "']'") + expr = Index( + line=token.line, column=token.column, target=expr, offset=offset + ) + continue + if token.type == "DOT": + self._advance() + field = self._expect("IDENT", "field name") + expr = Member( + line=token.line, + column=token.column, + target=expr, + field_name=field.value, + ) + continue + return expr + + def _parse_primary(self) -> Expr: + token = self._peek() + + if token.type in ("INT", "FLOAT"): + self._advance() + return NumberLit( + line=token.line, + column=token.column, + value=int(token.value) if token.type == "INT" else float(token.value), + ) + if token.type == "STRING": + self._advance() + return StringLit( + line=token.line, + column=token.column, + value=decode_string(token.value), + ) + if token.type == "INTERVAL": + self._advance() + return DurationLit(line=token.line, column=token.column, text=token.value) + if token.type == "LPAREN": + self._advance() + inner = self._parse_expr() + self._expect("RPAREN", "')'") + return inner + if token.type == "IDENT": + if token.value in ("true", "false"): + self._advance() + return BoolLit( + line=token.line, column=token.column, value=token.value == "true" + ) + self._advance() + if self._peek().type != "LPAREN": + return Name(line=token.line, column=token.column, name=token.value) + self._advance() + args: List[Expr] = [] + if self._peek().type != "RPAREN": + args.append(self._parse_expr()) + while self._peek().type == "COMMA": + self._advance() + args.append(self._parse_expr()) + self._expect("RPAREN", "')'") + return Call( + line=token.line, + column=token.column, + callee=token.value, + args=tuple(args), + ) + + raise self._error(f"Expected an expression, got {self._describe(token)}", token) + + # -- literals ---------------------------------------------------------- + + def _parse_literal(self) -> Literal: + token = self._peek() + if token.type == "STRING": + self._advance() + return decode_string(token.value) + if token.type == "IDENT" and token.value in ("true", "false"): + self._advance() + return token.value == "true" + return self._parse_number("literal value") + + def _parse_number(self, what: str) -> Number: + negative = False + if self._at_op("-"): + self._advance() + negative = True + token = self._peek() + if token.type == "INT": + self._advance() + value: Number = int(token.value) + elif token.type == "FLOAT": + self._advance() + value = float(token.value) + else: + raise self._error( + f"Expected numeric {what}, got {self._describe(token)}", token + ) + return -value if negative else value + def parse(source: str) -> StrategyAst: - """Parse `.nano` source into a validated, immutable AST.""" + """Parse `.nano` source into an immutable, well-formed AST.""" return _Parser(tokenize(source)).parse_program() + + +__all__ = [ + "ActionAst", + "AgentAst", + "ConditionAst", + "EscalateStmt", + "IfStmt", + "InputAst", + "LetAst", + "ParamAst", + "RiskAst", + "RiskLimitAst", + "RouteAst", + "RuleAst", + "ScheduleAst", + "SigFieldAst", + "SignatureAst", + "StrategyAst", + "parse", +] diff --git a/nano/compiler/tokens.py b/nano/compiler/tokens.py index 7d4e64a..fccbff1 100644 --- a/nano/compiler/tokens.py +++ b/nano/compiler/tokens.py @@ -17,12 +17,21 @@ "IDENT", # [A-Za-z_][A-Za-z0-9_]* "INT", # integer literal, e.g. 30 "FLOAT", # decimal literal, e.g. 0.91 + "STRING", # quoted literal, e.g. "research-agent" "INTERVAL", # INT + unit, e.g. 5m, 1h - "OP", # < <= > >= == != + # Every operator shares one token type; the parser reads `value` to tell + # `<` from `+`. Keeping arithmetic and comparison together means editor + # tooling that maps OP -> "operator" needs no change as the grammar grows. + "OP", # < <= > >= == != + - * / % + "ASSIGN", # = -- distinct from ==, so `if x = 1` reports a real diagnostic + "COLON", # : in type annotations + "DOT", # . in signature-result field access "LBRACE", "RBRACE", "LPAREN", "RPAREN", + "LBRACKET", # [ opening a series offset + "RBRACKET", "COMMA", "EOF", } diff --git a/nano/data/__init__.py b/nano/data/__init__.py new file mode 100644 index 0000000..aad1487 --- /dev/null +++ b/nano/data/__init__.py @@ -0,0 +1,32 @@ +"""Market-data adapters — the edge where files become frames. + +Nano reads data; it never fetches it. A historical CSV or JSON file becomes a +``MarketFrame`` here, and a live feed or broker connector is expected to implement +the host's side of the same contract: build frames, hand them in. There is no +socket and no credential in this package, which is what keeps "a Nano program +cannot act on the world" structural rather than aspirational. + +See ``frames.py`` for the accepted file shapes and the UTC timestamp rule. +""" + +from .frames import ( + TIMESTAMP_COLUMNS, + FeedError, + LoadedFrame, + load_csv, + load_frame, + load_json, + parse_date, + parse_timestamp, +) + +__all__ = [ + "FeedError", + "LoadedFrame", + "TIMESTAMP_COLUMNS", + "load_csv", + "load_frame", + "load_json", + "parse_date", + "parse_timestamp", +] diff --git a/nano/data/frames.py b/nano/data/frames.py new file mode 100644 index 0000000..bbd13ee --- /dev/null +++ b/nano/data/frames.py @@ -0,0 +1,287 @@ +"""Deterministic market-frame loading. + +This is the one place in Nano that reads a file, and it sits deliberately at the +edge. Everything downstream — the compiler, the VM, the bridge — is a pure +function of values already in memory, and it stays that way because loading +happens here and nowhere else. + +Two shapes are accepted, because both already exist in the wild: + + CSV timestamp,close,volume + 1767225600,101.5,1200 + + JSON {"timestamps": [0, 300], "signals": {"close": [101.5, 102.0]}} + [{"timestamp": 0, "close": 101.5}, {"timestamp": 300, "close": 102.0}] + +Rules that matter for reproducibility: + +**Timestamps become integers immediately.** An epoch-second integer is taken as +is; an ISO-8601 string is parsed as **UTC** unless it carries an explicit offset. +Reading a naive timestamp as local time would make the same CSV replay +differently on two machines — precisely the class of bug determinism exists to +eliminate. + +**Rows are sorted by timestamp and duplicates are rejected.** Indicators are +order-sensitive, so a file whose rows arrived out of order would produce +confident nonsense. The sort is stable, and a repeated timestamp is an error +rather than a silent last-one-wins. + +**Empty cells are absent, not zero.** A blank CSV field becomes `None`, which +propagates through the kernels as "no value" (see ``nano/indicators/compute.py``). +Filling a gap with zero would invent a price. + +Nano still never *fetches* anything. There is no socket here and no credential — a +live feed or broker connector implements the host's side of this contract and +hands frames in. That boundary is what makes "a Nano program cannot act on the +world" a structural claim rather than a policy. +""" + +from __future__ import annotations + +import csv +import json +from dataclasses import dataclass +from datetime import date, datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple + +from ..runtime.interpreter import MarketFrame + +# Column names accepted as the timeline, in priority order. +TIMESTAMP_COLUMNS = ("timestamp", "time", "ts", "date", "datetime") + +# Cell spellings that mean "no observation". Anything else non-numeric is an +# error: a typo should not quietly become a gap. +_ABSENT_CELLS = frozenset({"", "NA", "NAN", "NULL", "-"}) + + +class FeedError(ValueError): + """The data file could not be read as a market frame.""" + + +@dataclass(frozen=True) +class LoadedFrame: + """A frame plus what was dropped getting there, so a replay can report it.""" + + frame: MarketFrame + rows_read: int + rows_kept: int + signal_names: Tuple[str, ...] + + @property + def rows_filtered(self) -> int: + return self.rows_read - self.rows_kept + + +def parse_timestamp(raw: Any) -> int: + """Coerce a timestamp cell to epoch seconds. + + Accepts an integer (used as is), a numeric string, or ISO-8601. A naive + ISO-8601 value is read as UTC: guessing the reader's local zone would make the + same file replay differently in two places. + """ + if isinstance(raw, bool): + raise FeedError(f"Timestamp {raw!r} is not a time") + if isinstance(raw, int): + return raw + if isinstance(raw, float): + return int(raw) + if not isinstance(raw, str) or not raw.strip(): + raise FeedError(f"Timestamp {raw!r} is empty or not a scalar") + + text = raw.strip() + try: + return int(text) + except ValueError: + pass + + normalised = text[:-1] + "+00:00" if text.endswith("Z") else text + try: + parsed = datetime.fromisoformat(normalised) + except ValueError as exc: + raise FeedError( + f"Timestamp {raw!r} is neither epoch seconds nor ISO-8601" + ) from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return int(parsed.timestamp()) + + +def parse_date(text: str) -> date: + """Parse a `--date` argument as a UTC calendar date.""" + try: + return date.fromisoformat(text) + except ValueError as exc: + raise FeedError(f"Date {text!r} is not ISO-8601 (expected YYYY-MM-DD)") from exc + + +def _parse_cell(raw: Any) -> Optional[float]: + """A numeric cell, or None when the cell records no observation.""" + if raw is None: + return None + if isinstance(raw, bool): + return 1.0 if raw else 0.0 + if isinstance(raw, (int, float)): + return float(raw) + text = str(raw).strip() + if text.upper() in _ABSENT_CELLS: + return None + try: + return float(text) + except ValueError as exc: + raise FeedError(f"Cell {raw!r} is not numeric") from exc + + +def _timestamp_column(fieldnames: Sequence[str]) -> str: + lowered = {name.lower(): name for name in fieldnames} + for candidate in TIMESTAMP_COLUMNS: + if candidate in lowered: + return lowered[candidate] + raise FeedError( + "No timestamp column found (expected one of " + f"{', '.join(TIMESTAMP_COLUMNS)}), got: {', '.join(fieldnames)}" + ) + + +def _matches_date(timestamp: int, wanted: Optional[date]) -> bool: + if wanted is None: + return True + return datetime.fromtimestamp(timestamp, tz=timezone.utc).date() == wanted + + +def _rows_to_frame( + rows: List[Tuple[int, Mapping[str, Optional[float]]]], + signal_names: Sequence[str], + *, + rows_read: int, +) -> LoadedFrame: + """Assemble sorted rows into a frame, rejecting duplicate timestamps.""" + # Stable sort by timestamp: indicators are order-sensitive, and a file whose + # rows arrived out of order would otherwise produce confident nonsense. + rows.sort(key=lambda item: item[0]) + + seen: Set[int] = set() + for timestamp, _ in rows: + if timestamp in seen: + raise FeedError( + f"Duplicate timestamp {timestamp} — a bar cannot occur twice, and " + "silently keeping one of them would change the result" + ) + seen.add(timestamp) + + signals: Dict[str, Tuple[Optional[float], ...]] = { + name: tuple(values.get(name) for _, values in rows) for name in signal_names + } + return LoadedFrame( + frame=MarketFrame( + timestamps=tuple(timestamp for timestamp, _ in rows), signals=signals + ), + rows_read=rows_read, + rows_kept=len(rows), + signal_names=tuple(signal_names), + ) + + +def load_csv(path: Path, *, on_date: Optional[date] = None) -> LoadedFrame: + """Read a CSV whose header names the timeline and one column per signal.""" + with path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + if reader.fieldnames is None: + raise FeedError(f"{path} has no header row") + time_column = _timestamp_column(reader.fieldnames) + signal_names = [name for name in reader.fieldnames if name != time_column] + if not signal_names: + raise FeedError(f"{path} declares a timeline but no signal columns") + + rows: List[Tuple[int, Mapping[str, Optional[float]]]] = [] + rows_read = 0 + for record in reader: + rows_read += 1 + timestamp = parse_timestamp(record[time_column]) + if not _matches_date(timestamp, on_date): + continue + rows.append( + ( + timestamp, + {name: _parse_cell(record.get(name)) for name in signal_names}, + ) + ) + return _rows_to_frame(rows, signal_names, rows_read=rows_read) + + +def _load_columnar( + document: Mapping[str, Any], *, on_date: Optional[date] +) -> LoadedFrame: + raw_timestamps = document.get("timestamps") + raw_signals = document.get("signals") + if not isinstance(raw_timestamps, list) or not isinstance(raw_signals, Mapping): + raise FeedError( + "Columnar JSON requires 'timestamps' (a list) and 'signals' (an object)" + ) + + timestamps = [parse_timestamp(t) for t in raw_timestamps] + signal_names = list(raw_signals) + for name, values in raw_signals.items(): + if not isinstance(values, list) or len(values) != len(timestamps): + length = len(values) if isinstance(values, list) else "?" + raise FeedError( + f"Signal {name!r} has {length} points for {len(timestamps)} timestamps" + ) + + rows = [ + ( + timestamp, + {name: _parse_cell(raw_signals[name][position]) for name in signal_names}, + ) + for position, timestamp in enumerate(timestamps) + if _matches_date(timestamp, on_date) + ] + return _rows_to_frame(rows, signal_names, rows_read=len(timestamps)) + + +def _load_records(records: Sequence[Any], *, on_date: Optional[date]) -> LoadedFrame: + if not records: + raise FeedError("Row-list JSON is empty") + if not all(isinstance(record, Mapping) for record in records): + raise FeedError("Row-list JSON must contain objects") + + time_column = _timestamp_column(list(records[0])) + signal_names = [name for name in records[0] if name != time_column] + if not signal_names: + raise FeedError("Row-list JSON declares a timeline but no signal fields") + + rows: List[Tuple[int, Mapping[str, Optional[float]]]] = [] + for record in records: + timestamp = parse_timestamp(record[time_column]) + if not _matches_date(timestamp, on_date): + continue + rows.append( + (timestamp, {name: _parse_cell(record.get(name)) for name in signal_names}) + ) + return _rows_to_frame(rows, signal_names, rows_read=len(records)) + + +def load_json(path: Path, *, on_date: Optional[date] = None) -> LoadedFrame: + """Read either JSON shape: columnar `{timestamps, signals}` or a row list.""" + try: + document = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise FeedError(f"{path} is not valid JSON: {exc}") from exc + + if isinstance(document, Mapping): + return _load_columnar(document, on_date=on_date) + if isinstance(document, list): + return _load_records(document, on_date=on_date) + raise FeedError(f"{path} must contain an object or a list of rows") + + +def load_frame(path: Path, *, on_date: Optional[date] = None) -> LoadedFrame: + """Load a market frame, choosing the reader by file extension.""" + if not path.exists(): + raise FeedError(f"{path} does not exist") + suffix = path.suffix.lower() + if suffix == ".csv": + return load_csv(path, on_date=on_date) + if suffix == ".json": + return load_json(path, on_date=on_date) + raise FeedError(f"Unsupported data format {suffix!r} (expected .csv or .json)") diff --git a/nano/indicators/__init__.py b/nano/indicators/__init__.py new file mode 100644 index 0000000..367cb72 --- /dev/null +++ b/nano/indicators/__init__.py @@ -0,0 +1,22 @@ +"""Indicator layer — typed signatures plus deterministic reference kernels. + +Two halves that stay separable on purpose: ``registry`` is the compile-time +contract the type checker reads (arity, parameter kinds, warm-up length), and +``compute`` is the runtime math. A host that only compiles never imports the +kernels; a host that swaps in vectorised kernels keeps the same signatures. +""" + +from .compute import Cell, Series, UnknownIndicator, evaluate +from .registry import INDICATORS, IndicatorSpec, is_indicator, lookup, names + +__all__ = [ + "Cell", + "INDICATORS", + "IndicatorSpec", + "Series", + "UnknownIndicator", + "evaluate", + "is_indicator", + "lookup", + "names", +] diff --git a/nano/indicators/compute.py b/nano/indicators/compute.py new file mode 100644 index 0000000..68d31a6 --- /dev/null +++ b/nano/indicators/compute.py @@ -0,0 +1,631 @@ +"""Deterministic indicator kernels. + +Every kernel is a pure function of its inputs: no ambient clock, no RNG, no +accumulated instance state. Same series in, same series out, bit-for-bit, which +is what lets a compiled strategy replay identically a year later. + +**Cells may be absent.** A series cell is `None` when no value exists — either +the indicator has not warmed up yet, or the feed had a gap. Absence is never +filled in. Fabricating a warm-up value is the mirror image of look-ahead: both +invent data the strategy could not have had, and both inflate a backtest. + +**Recursive indicators reset on a gap.** EMA, RSI, ATR, and OBV carry state +across bars, so they operate on *contiguous runs* of present values: a `None` +clears the accumulator and the kernel re-seeds from the next full window. The +alternative — smoothing across a hole as if it were not there — would make the +result depend on how the feed was chunked. + +Conventions where the classic formula divides by zero are pinned here rather +than left to the caller, because an unpinned convention is a silent divergence +between two runtimes: + +| Situation | Value | +|---|---| +| RSI with no losses in the window | `100.0` | +| ZSCORE / CCI with zero dispersion | `0.0` | +| STOCH_K with a flat range | `50.0` | +| WILLR with a flat range | `-50.0` | +| BB_PCT_B with zero band width | `0.5` | +| ROC from a zero base, VWAP with zero volume, BB_WIDTH on a zero mid | absent | +| SQRT of a negative | absent | +""" + +from __future__ import annotations + +from typing import Callable, Dict, Optional, Sequence, Tuple, Union + +Cell = Optional[Union[float, bool]] +Series = Tuple[Cell, ...] +Scalar = Union[int, float, bool, None] + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _floats(series: Sequence[Cell]) -> Tuple[Optional[float], ...]: + """View a series as floats, preserving absence.""" + return tuple(None if c is None else float(c) for c in series) + + +def _cell(value: Union[Series, Scalar], index: int) -> Optional[float]: + """Read `value` at `index`, broadcasting scalars across every bar.""" + if isinstance(value, tuple): + cell = value[index] if index < len(value) else None + return None if cell is None else float(cell) + return None if value is None else float(value) + + +def _window( + values: Tuple[Optional[float], ...], index: int, period: int +) -> Optional[Tuple[float, ...]]: + """The `period` bars ending at `index`, or None if incomplete or gapped.""" + if index + 1 < period: + return None + window = values[index + 1 - period : index + 1] + if any(v is None for v in window): + return None + return tuple(float(v) for v in window) + + +def _apply(fn, *args: Optional[float]) -> Optional[float]: + if any(a is None for a in args): + return None + return fn(*args) + + +def _map1(values: Union[Series, Scalar], length: int, fn) -> Series: + return tuple(_apply(fn, _cell(values, i)) for i in range(length)) + + +def _map2(a: Union[Series, Scalar], b: Union[Series, Scalar], length: int, fn) -> Series: + return tuple(_apply(fn, _cell(a, i), _cell(b, i)) for i in range(length)) + + +# --------------------------------------------------------------------------- +# moving averages and dispersion +# --------------------------------------------------------------------------- + + +def sma(values: Series, period: int) -> Series: + data = _floats(values) + out: list[Cell] = [] + for i in range(len(data)): + window = _window(data, i, period) + out.append(None if window is None else sum(window) / period) + return tuple(out) + + +def ema(values: Series, period: int) -> Series: + """Alpha = 2/(period+1), seeded with the first complete `period`-bar mean.""" + data = _floats(values) + alpha = 2.0 / (period + 1) + out: list[Cell] = [] + run: list[float] = [] + state: Optional[float] = None + for value in data: + if value is None: + run = [] + state = None + out.append(None) + continue + run.append(value) + if len(run) < period: + out.append(None) + elif len(run) == period: + state = sum(run) / period + out.append(state) + else: + state = alpha * value + (1.0 - alpha) * float(state) + out.append(state) + return tuple(out) + + +def wma(values: Series, period: int) -> Series: + data = _floats(values) + denominator = period * (period + 1) / 2.0 + out: list[Cell] = [] + for i in range(len(data)): + window = _window(data, i, period) + if window is None: + out.append(None) + continue + weighted = sum(value * (offset + 1) for offset, value in enumerate(window)) + out.append(weighted / denominator) + return tuple(out) + + +def stddev(values: Series, period: int) -> Series: + """Population standard deviation — divides by `period`, not `period - 1`.""" + data = _floats(values) + out: list[Cell] = [] + for i in range(len(data)): + window = _window(data, i, period) + if window is None: + out.append(None) + continue + mean = sum(window) / period + variance = sum((value - mean) ** 2 for value in window) / period + out.append(variance ** 0.5) + return tuple(out) + + +def zscore(values: Series, period: int) -> Series: + data = _floats(values) + means = sma(values, period) + deviations = stddev(values, period) + out: list[Cell] = [] + for i in range(len(data)): + value, mean, deviation = data[i], means[i], deviations[i] + if value is None or mean is None or deviation is None: + out.append(None) + elif float(deviation) == 0.0: + out.append(0.0) + else: + out.append((value - float(mean)) / float(deviation)) + return tuple(out) + + +def rolling_sum(values: Series, period: int) -> Series: + data = _floats(values) + out: list[Cell] = [] + for i in range(len(data)): + window = _window(data, i, period) + out.append(None if window is None else sum(window)) + return tuple(out) + + +# --------------------------------------------------------------------------- +# momentum +# --------------------------------------------------------------------------- + + +def rsi(values: Series, period: int) -> Series: + """Wilder's RSI. Seeds on the first `period` differences of a contiguous run.""" + data = _floats(values) + out: list[Cell] = [] + gains: list[float] = [] + losses: list[float] = [] + previous: Optional[float] = None + average_gain: Optional[float] = None + average_loss: Optional[float] = None + + for value in data: + if value is None: + previous = None + gains, losses = [], [] + average_gain = average_loss = None + out.append(None) + continue + if previous is None: + previous = value + out.append(None) + continue + + difference = value - previous + previous = value + gain = max(difference, 0.0) + loss = max(-difference, 0.0) + + if average_gain is None: + gains.append(gain) + losses.append(loss) + if len(gains) < period: + out.append(None) + continue + average_gain = sum(gains) / period + average_loss = sum(losses) / period + else: + average_gain = (average_gain * (period - 1) + gain) / period + average_loss = (float(average_loss) * (period - 1) + loss) / period + + if average_loss == 0.0: + out.append(100.0) + else: + rs = average_gain / float(average_loss) + out.append(100.0 - 100.0 / (1.0 + rs)) + return tuple(out) + + +def roc(values: Series, period: int) -> Series: + data = _floats(values) + out: list[Cell] = [] + for i in range(len(data)): + current = data[i] + base = data[i - period] if i >= period else None + if current is None or base is None or base == 0.0: + out.append(None) + else: + out.append((current - base) / base * 100.0) + return tuple(out) + + +def momentum(values: Series, period: int) -> Series: + data = _floats(values) + out: list[Cell] = [] + for i in range(len(data)): + current = data[i] + base = data[i - period] if i >= period else None + out.append(None if current is None or base is None else current - base) + return tuple(out) + + +def change(values: Series) -> Series: + return momentum(values, 1) + + +# --------------------------------------------------------------------------- +# extremes +# --------------------------------------------------------------------------- + + +def highest(values: Series, period: int) -> Series: + data = _floats(values) + out: list[Cell] = [] + for i in range(len(data)): + window = _window(data, i, period) + out.append(None if window is None else max(window)) + return tuple(out) + + +def lowest(values: Series, period: int) -> Series: + data = _floats(values) + out: list[Cell] = [] + for i in range(len(data)): + window = _window(data, i, period) + out.append(None if window is None else min(window)) + return tuple(out) + + +# --------------------------------------------------------------------------- +# range and volatility +# --------------------------------------------------------------------------- + + +def true_range(high: Series, low: Series, close: Series) -> Series: + highs, lows, closes = _floats(high), _floats(low), _floats(close) + out: list[Cell] = [] + for i in range(len(highs)): + previous_close = closes[i - 1] if i >= 1 else None + if highs[i] is None or lows[i] is None or previous_close is None: + out.append(None) + continue + out.append( + max( + float(highs[i]) - float(lows[i]), + abs(float(highs[i]) - previous_close), + abs(float(lows[i]) - previous_close), + ) + ) + return tuple(out) + + +def atr(high: Series, low: Series, close: Series, period: int) -> Series: + """Wilder-smoothed average true range, seeded with the first `period` TRs.""" + ranges = _floats(true_range(high, low, close)) + out: list[Cell] = [] + run: list[float] = [] + state: Optional[float] = None + for value in ranges: + if value is None: + run = [] + state = None + out.append(None) + continue + if state is None: + run.append(value) + if len(run) < period: + out.append(None) + continue + state = sum(run) / period + else: + state = (state * (period - 1) + value) / period + out.append(state) + return tuple(out) + + +# --------------------------------------------------------------------------- +# oscillators over (high, low, close) +# --------------------------------------------------------------------------- + + +def stoch_k(high: Series, low: Series, close: Series, period: int) -> Series: + highs, lows = highest(high, period), lowest(low, period) + closes = _floats(close) + out: list[Cell] = [] + for i in range(len(closes)): + top, bottom, current = highs[i], lows[i], closes[i] + if top is None or bottom is None or current is None: + out.append(None) + continue + span = float(top) - float(bottom) + out.append(50.0 if span == 0.0 else (current - float(bottom)) / span * 100.0) + return tuple(out) + + +def willr(high: Series, low: Series, close: Series, period: int) -> Series: + highs, lows = highest(high, period), lowest(low, period) + closes = _floats(close) + out: list[Cell] = [] + for i in range(len(closes)): + top, bottom, current = highs[i], lows[i], closes[i] + if top is None or bottom is None or current is None: + out.append(None) + continue + span = float(top) - float(bottom) + out.append(-50.0 if span == 0.0 else -(float(top) - current) / span * 100.0) + return tuple(out) + + +def cci(high: Series, low: Series, close: Series, period: int) -> Series: + highs, lows, closes = _floats(high), _floats(low), _floats(close) + typical: Tuple[Cell, ...] = tuple( + None + if highs[i] is None or lows[i] is None or closes[i] is None + else (float(highs[i]) + float(lows[i]) + float(closes[i])) / 3.0 + for i in range(len(closes)) + ) + typical_floats = _floats(typical) + means = sma(typical, period) + out: list[Cell] = [] + for i in range(len(typical_floats)): + window = _window(typical_floats, i, period) + current, mean = typical_floats[i], means[i] + if window is None or current is None or mean is None: + out.append(None) + continue + deviation = sum(abs(value - float(mean)) for value in window) / period + out.append( + 0.0 if deviation == 0.0 else (current - float(mean)) / (0.015 * deviation) + ) + return tuple(out) + + +# --------------------------------------------------------------------------- +# volume +# --------------------------------------------------------------------------- + + +def obv(close: Series, volume: Series) -> Series: + closes, volumes = _floats(close), _floats(volume) + out: list[Cell] = [] + previous: Optional[float] = None + state: Optional[float] = None + for i in range(len(closes)): + current, size = closes[i], volumes[i] + if current is None or size is None: + previous = None + state = None + out.append(None) + continue + if previous is None: + previous = current + out.append(None) + continue + if state is None: + state = 0.0 + if current > previous: + state += size + elif current < previous: + state -= size + previous = current + out.append(state) + return tuple(out) + + +def vwap(price: Series, volume: Series, period: int) -> Series: + prices, volumes = _floats(price), _floats(volume) + out: list[Cell] = [] + for i in range(len(prices)): + price_window = _window(prices, i, period) + volume_window = _window(volumes, i, period) + if price_window is None or volume_window is None: + out.append(None) + continue + total_volume = sum(volume_window) + if total_volume == 0.0: + out.append(None) + continue + notional = sum(p * v for p, v in zip(price_window, volume_window)) + out.append(notional / total_volume) + return tuple(out) + + +# --------------------------------------------------------------------------- +# MACD family +# --------------------------------------------------------------------------- + + +def macd_line(values: Series, fast: int, slow: int) -> Series: + return _map2(ema(values, fast), ema(values, slow), len(values), lambda a, b: a - b) + + +def macd_signal(values: Series, fast: int, slow: int, signal: int) -> Series: + return ema(macd_line(values, fast, slow), signal) + + +def macd_hist(values: Series, fast: int, slow: int, signal: int) -> Series: + line = macd_line(values, fast, slow) + return _map2(line, ema(line, signal), len(values), lambda a, b: a - b) + + +# --------------------------------------------------------------------------- +# Bollinger family +# --------------------------------------------------------------------------- + + +def bb_middle(values: Series, period: int) -> Series: + return sma(values, period) + + +def _bollinger( + values: Series, period: int, mult: Union[Series, Scalar], *, sign: float +) -> Series: + middle, deviations = sma(values, period), stddev(values, period) + out: list[Cell] = [] + for i in range(len(values)): + mid, deviation, multiple = middle[i], deviations[i], _cell(mult, i) + if mid is None or deviation is None or multiple is None: + out.append(None) + else: + out.append(float(mid) + sign * multiple * float(deviation)) + return tuple(out) + + +def bb_upper(values: Series, period: int, mult: Union[Series, Scalar]) -> Series: + return _bollinger(values, period, mult, sign=+1.0) + + +def bb_lower(values: Series, period: int, mult: Union[Series, Scalar]) -> Series: + return _bollinger(values, period, mult, sign=-1.0) + + +def bb_pct_b(values: Series, period: int, mult: Union[Series, Scalar]) -> Series: + data = _floats(values) + upper = bb_upper(values, period, mult) + lower = bb_lower(values, period, mult) + out: list[Cell] = [] + for i in range(len(data)): + value, top, bottom = data[i], upper[i], lower[i] + if value is None or top is None or bottom is None: + out.append(None) + continue + span = float(top) - float(bottom) + out.append(0.5 if span == 0.0 else (value - float(bottom)) / span) + return tuple(out) + + +def bb_width(values: Series, period: int, mult: Union[Series, Scalar]) -> Series: + middle = sma(values, period) + upper = bb_upper(values, period, mult) + lower = bb_lower(values, period, mult) + out: list[Cell] = [] + for i in range(len(values)): + mid, top, bottom = middle[i], upper[i], lower[i] + if mid is None or top is None or bottom is None or float(mid) == 0.0: + out.append(None) + else: + out.append((float(top) - float(bottom)) / float(mid) * 100.0) + return tuple(out) + + +# --------------------------------------------------------------------------- +# crosses +# --------------------------------------------------------------------------- + + +def _cross(left: Series, right: Series, *, above: bool) -> Series: + a, b = _floats(left), _floats(right) + out: list[Cell] = [] + for i in range(len(a)): + if ( + i == 0 + or a[i] is None + or b[i] is None + or a[i - 1] is None + or b[i - 1] is None + ): + out.append(None) + continue + if above: + out.append(a[i] > b[i] and a[i - 1] <= b[i - 1]) + else: + out.append(a[i] < b[i] and a[i - 1] >= b[i - 1]) + return tuple(out) + + +def crossover(left: Series, right: Series) -> Series: + return _cross(left, right, above=True) + + +def crossunder(left: Series, right: Series) -> Series: + return _cross(left, right, above=False) + + +# --------------------------------------------------------------------------- +# elementwise scalar maths +# --------------------------------------------------------------------------- + + +def absolute(values: Union[Series, Scalar], *, length: int) -> Series: + return _map1(values, length, abs) + + +def square_root(values: Union[Series, Scalar], *, length: int) -> Series: + return _map1(values, length, lambda v: None if v < 0.0 else v ** 0.5) + + +def minimum( + a: Union[Series, Scalar], b: Union[Series, Scalar], *, length: int +) -> Series: + return _map2(a, b, length, min) + + +def maximum( + a: Union[Series, Scalar], b: Union[Series, Scalar], *, length: int +) -> Series: + return _map2(a, b, length, max) + + +# --------------------------------------------------------------------------- +# dispatch +# --------------------------------------------------------------------------- + +# Kernels that need the frame length because a scalar argument may have to be +# broadcast across every bar; the others derive length from their series input. +_LENGTH_AWARE = frozenset({"ABS", "SQRT", "MIN", "MAX"}) + +_KERNELS: Dict[str, Callable[..., Series]] = { + "SMA": sma, + "EMA": ema, + "WMA": wma, + "STDDEV": stddev, + "ZSCORE": zscore, + "SUM": rolling_sum, + "RSI": rsi, + "ROC": roc, + "MOM": momentum, + "CHANGE": change, + "HIGHEST": highest, + "LOWEST": lowest, + "TR": true_range, + "ATR": atr, + "STOCH_K": stoch_k, + "WILLR": willr, + "CCI": cci, + "OBV": obv, + "VWAP": vwap, + "MACD_LINE": macd_line, + "MACD_SIGNAL": macd_signal, + "MACD_HIST": macd_hist, + "BB_MIDDLE": bb_middle, + "BB_UPPER": bb_upper, + "BB_LOWER": bb_lower, + "BB_PCT_B": bb_pct_b, + "BB_WIDTH": bb_width, + "CROSSOVER": crossover, + "CROSSUNDER": crossunder, + "ABS": absolute, + "SQRT": square_root, + "MIN": minimum, + "MAX": maximum, +} + + +class UnknownIndicator(KeyError): + """No kernel is registered under that name.""" + + +def evaluate(name: str, args: Sequence[object], *, length: int) -> Series: + """Run the kernel for `name`. `length` is the frame's bar count. + + The type checker has already validated arity, parameter kinds, and period + constancy, so a failure here is a compiler bug rather than a user error. + """ + kernel = _KERNELS.get(name) + if kernel is None: + raise UnknownIndicator(name) + if name in _LENGTH_AWARE: + return kernel(*args, length=length) + return kernel(*args) diff --git a/nano/indicators/registry.py b/nano/indicators/registry.py new file mode 100644 index 0000000..6d5e63d --- /dev/null +++ b/nano/indicators/registry.py @@ -0,0 +1,263 @@ +"""Indicator signatures — the typed contract between source and computation. + +Until v1.0 Nano never computed an indicator: `RSI(14) < 30` named a signal the +host feed supplied, and the `(14)` was documentation. That **feed-signal form** +still works and still compiles to a bare `Condition` node. v1.0 adds the +**computed form** — `EMA(price, 20)`, where `price` is a declared `input` and +Nano derives the series itself, deterministically, from data already in the +frame. + +The two forms are distinguished structurally, never by heuristic: + + RSI(14) one static integer, no series -> feed signal, host-supplied + RSI(close, 14) a series argument -> computed by nano/indicators + +Three parameter kinds, and the difference matters: + +* `series` — history-consuming. The argument must really be a series; a + scalar cannot satisfy it, because the kernel needs prior bars. +* `int` — a **period**. Must be a compile-time constant so warm-up length is + known before any data arrives. Periods never lift over series. +* `float` — a plain runtime value. May be scalar or series; if any `float` + argument is a series the whole call lifts and evaluates elementwise. + +`lookback` is how many bars must elapse before a value exists. It is a function +of the resolved period arguments, not a constant, because MACD's warm-up depends +on three of them. The VM refuses to emit an intent from an unwarmed bar, which +is the other half of look-ahead safety: never peek forward, never fabricate +backward. + +**This module is a leaf.** Parameter and return types are the canonical *type +spellings* (`"series"`), not `nano.types` objects, and the checker parses +them with `parse_type`. Importing the type system here would close a cycle — +`indicators` -> `types` -> `checker` -> `indicators` — whose failure depends on +which package a caller happens to import first. A declarative table that the +type system interprets has no such ordering hazard, and it keeps the registry +readable as data. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Dict, Optional, Tuple + +# Canonical type spellings, matching nano.types.kinds.Type.__str__. +INT = "int" +FLOAT = "float" +SERIES_FLOAT = "series" +SERIES_BOOL = "series" + +# A lookback rule maps the call's resolved period arguments to a warm-up length. +LookbackRule = Callable[[Tuple[int, ...]], int] + + +@dataclass(frozen=True) +class IndicatorSpec: + """One indicator's type signature, warm-up rule, and documentation.""" + + name: str + params: Tuple[str, ...] + returns: str + lookback: LookbackRule + doc: str + + @property + def arity(self) -> int: + return len(self.params) + + @property + def period_indices(self) -> Tuple[int, ...]: + """Positions of the `int` parameters — the ones that must be constant.""" + return tuple(i for i, p in enumerate(self.params) if p == INT) + + @property + def series_indices(self) -> Tuple[int, ...]: + """Positions that require a genuine series argument.""" + return tuple(i for i, p in enumerate(self.params) if p.startswith("series<")) + + @property + def lifts(self) -> bool: + """True when a scalar `float` parameter may receive a series and lift.""" + return any(p == FLOAT for p in self.params) + + def signature_text(self) -> str: + """`EMA(series, int) -> series` — for hovers and `--emit types`.""" + return f"{self.name}({', '.join(self.params)}) -> {self.returns}" + + +def _fixed(n: int) -> LookbackRule: + return lambda _periods: n + + +def _first_period(offset: int = 0) -> LookbackRule: + """Warm-up derived from the first period argument, plus `offset`.""" + return lambda periods: (periods[0] + offset if periods else 0) + + +def _max_period(offset: int = 0) -> LookbackRule: + """Warm-up derived from the largest period argument, plus `offset`.""" + return lambda periods: (max(periods) + offset if periods else 0) + + +def _macd_signal_lookback(periods: Tuple[int, ...]) -> int: + """MACD signal/histogram warm up once the line is warm, then again for its EMA.""" + fast, slow, signal = periods[0], periods[1], periods[2] + return max(fast, slow) - 1 + signal - 1 + + +_SPECS: Tuple[IndicatorSpec, ...] = ( + # -- moving averages and dispersion ------------------------------------ + IndicatorSpec( + "SMA", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(-1), + "Simple moving average over the last `period` bars.", + ), + IndicatorSpec( + "EMA", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(-1), + "Exponential moving average, alpha = 2/(period+1), seeded with the " + "first `period`-bar SMA.", + ), + IndicatorSpec( + "WMA", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(-1), + "Linearly weighted moving average; the newest bar carries weight `period`.", + ), + IndicatorSpec( + "STDDEV", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(-1), + "Population standard deviation over the last `period` bars.", + ), + IndicatorSpec( + "ZSCORE", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(-1), + "(value - SMA) / STDDEV over `period` bars; 0 when dispersion is 0.", + ), + IndicatorSpec( + "SUM", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(-1), + "Rolling sum over the last `period` bars.", + ), + # -- momentum ----------------------------------------------------------- + IndicatorSpec( + "RSI", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(), + "Wilder's relative strength index, 0..100.", + ), + IndicatorSpec( + "ROC", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(), + "Rate of change as a percentage over `period` bars.", + ), + IndicatorSpec( + "MOM", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(), + "Absolute change over `period` bars: value - value[period].", + ), + IndicatorSpec( + "CHANGE", (SERIES_FLOAT,), SERIES_FLOAT, _fixed(1), + "Bar-over-bar difference: value - value[1].", + ), + # -- extremes ----------------------------------------------------------- + IndicatorSpec( + "HIGHEST", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(-1), + "Highest value over the last `period` bars.", + ), + IndicatorSpec( + "LOWEST", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(-1), + "Lowest value over the last `period` bars.", + ), + # -- range and volatility ----------------------------------------------- + IndicatorSpec( + "TR", (SERIES_FLOAT, SERIES_FLOAT, SERIES_FLOAT), SERIES_FLOAT, _fixed(1), + "True range from (high, low, close).", + ), + IndicatorSpec( + "ATR", (SERIES_FLOAT, SERIES_FLOAT, SERIES_FLOAT, INT), SERIES_FLOAT, + _first_period(), + "Average true range from (high, low, close), Wilder-smoothed.", + ), + # -- oscillators over (high, low, close) -------------------------------- + IndicatorSpec( + "STOCH_K", (SERIES_FLOAT, SERIES_FLOAT, SERIES_FLOAT, INT), SERIES_FLOAT, + _first_period(-1), + "Stochastic %K over `period` bars, 0..100.", + ), + IndicatorSpec( + "WILLR", (SERIES_FLOAT, SERIES_FLOAT, SERIES_FLOAT, INT), SERIES_FLOAT, + _first_period(-1), + "Williams %R over `period` bars, -100..0.", + ), + IndicatorSpec( + "CCI", (SERIES_FLOAT, SERIES_FLOAT, SERIES_FLOAT, INT), SERIES_FLOAT, + _first_period(-1), + "Commodity channel index over `period` bars.", + ), + # -- volume ------------------------------------------------------------- + IndicatorSpec( + "OBV", (SERIES_FLOAT, SERIES_FLOAT), SERIES_FLOAT, _fixed(1), + "On-balance volume from (close, volume); starts at 0 on the first warm bar.", + ), + IndicatorSpec( + "VWAP", (SERIES_FLOAT, SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(-1), + "Rolling volume-weighted average price from (price, volume) over `period`.", + ), + # -- MACD family -------------------------------------------------------- + IndicatorSpec( + "MACD_LINE", (SERIES_FLOAT, INT, INT), SERIES_FLOAT, _max_period(-1), + "EMA(fast) - EMA(slow).", + ), + IndicatorSpec( + "MACD_SIGNAL", (SERIES_FLOAT, INT, INT, INT), SERIES_FLOAT, + _macd_signal_lookback, + "EMA(signal) of the MACD line.", + ), + IndicatorSpec( + "MACD_HIST", (SERIES_FLOAT, INT, INT, INT), SERIES_FLOAT, + _macd_signal_lookback, + "MACD line minus its signal line.", + ), + # -- Bollinger family --------------------------------------------------- + IndicatorSpec( + "BB_MIDDLE", (SERIES_FLOAT, INT), SERIES_FLOAT, _first_period(-1), + "Bollinger middle band: SMA(period).", + ), + IndicatorSpec( + "BB_UPPER", (SERIES_FLOAT, INT, FLOAT), SERIES_FLOAT, _first_period(-1), + "Bollinger upper band: SMA + mult * STDDEV.", + ), + IndicatorSpec( + "BB_LOWER", (SERIES_FLOAT, INT, FLOAT), SERIES_FLOAT, _first_period(-1), + "Bollinger lower band: SMA - mult * STDDEV.", + ), + IndicatorSpec( + "BB_PCT_B", (SERIES_FLOAT, INT, FLOAT), SERIES_FLOAT, _first_period(-1), + "Position within the bands: (value - lower) / (upper - lower).", + ), + IndicatorSpec( + "BB_WIDTH", (SERIES_FLOAT, INT, FLOAT), SERIES_FLOAT, _first_period(-1), + "Band width as a percentage of the middle band.", + ), + # -- crosses ------------------------------------------------------------ + IndicatorSpec( + "CROSSOVER", (SERIES_FLOAT, SERIES_FLOAT), SERIES_BOOL, _fixed(1), + "True on the bar where the first series crosses above the second.", + ), + IndicatorSpec( + "CROSSUNDER", (SERIES_FLOAT, SERIES_FLOAT), SERIES_BOOL, _fixed(1), + "True on the bar where the first series crosses below the second.", + ), + # -- elementwise scalar maths (lift over series) ------------------------ + IndicatorSpec("ABS", (FLOAT,), FLOAT, _fixed(0), "Absolute value."), + IndicatorSpec( + "SQRT", (FLOAT,), FLOAT, _fixed(0), + "Square root. A negative input yields no value for that bar.", + ), + IndicatorSpec("MIN", (FLOAT, FLOAT), FLOAT, _fixed(0), "Lesser of two values."), + IndicatorSpec("MAX", (FLOAT, FLOAT), FLOAT, _fixed(0), "Greater of two values."), +) + +INDICATORS: Dict[str, IndicatorSpec] = {spec.name: spec for spec in _SPECS} + + +def lookup(name: str) -> Optional[IndicatorSpec]: + return INDICATORS.get(name) + + +def names() -> Tuple[str, ...]: + return tuple(sorted(INDICATORS)) + + +def is_indicator(name: str) -> bool: + return name in INDICATORS diff --git a/nano/ir/__init__.py b/nano/ir/__init__.py index 878d27c..ccd6729 100644 --- a/nano/ir/__init__.py +++ b/nano/ir/__init__.py @@ -1,18 +1,97 @@ +"""Nano IR — the contract between the compiler and every runtime. + +Two live document versions, one executable form. ``StrategyGraph`` loads baseline +(v0.1.0) documents and is the reference semantics; ``NanoModule`` loads v1.0.0 and +is what runtimes actually evaluate. A baseline graph lifts into a module via +``StrategyGraph.to_module()``, so there is one evaluator and the two versions +cannot drift apart. See ``schema.py`` for how a version gets chosen. +""" + +from typing import Any, Mapping + from .graph import StrategyGraph +from .module import ( + ARITHMETIC_OPS, + COMPARISON_OPS, + COMPILER_NAME, + COMPILER_VERSION, + DETERMINISM_CONTRACT, + OPS, + InputDecl, + IRNode, + NanoModule, + OpSpec, + ParamDecl, + canonical_effects, +) from .nodes import AgentNode, ConditionNode, IntentNode, ScheduleNode from .schema import ( + NANO_IR_VERSION, + NANO_IR_VERSION_1_0, + NANO_IR_VERSION_BASELINE, + NANO_IR_VERSION_LATEST, + SUPPORTED_IR_VERSIONS, IRValidationError, ManifestViolation, - NANO_IR_VERSION, + TierViolation, ) __all__ = [ + "ARITHMETIC_OPS", "AgentNode", + "COMPARISON_OPS", + "COMPILER_NAME", + "COMPILER_VERSION", "ConditionNode", - "IntentNode", + "DETERMINISM_CONTRACT", + "IRNode", "IRValidationError", + "InputDecl", + "IntentNode", "ManifestViolation", "NANO_IR_VERSION", + "NANO_IR_VERSION_1_0", + "NANO_IR_VERSION_BASELINE", + "NANO_IR_VERSION_LATEST", + "NanoModule", + "OPS", + "OpSpec", + "ParamDecl", + "SUPPORTED_IR_VERSIONS", "ScheduleNode", "StrategyGraph", + "TierViolation", + "canonical_effects", + "load", + "load_module", ] + + +def load(document: Mapping[str, Any]): + """Load a Nano IR document of either version. + + Dispatches on `nanoIrVersion` so a caller holding an unknown document does not + have to guess which loader to reach for. Returns a ``StrategyGraph`` for + baseline and a ``NanoModule`` for v1.0; both validate on the way in. + """ + version = document.get("nanoIrVersion") + if version == NANO_IR_VERSION_BASELINE: + return StrategyGraph.from_dict(document) + if version == NANO_IR_VERSION_1_0: + return NanoModule.from_dict(document) + raise IRValidationError( + f"nanoIrVersion {version!r} unsupported " + f"(expected one of {', '.join(SUPPORTED_IR_VERSIONS)})" + ) + + +def load_module(document: Mapping[str, Any]) -> NanoModule: + """Load any Nano IR document as an executable ``NanoModule``. + + Baseline documents are lifted on the way through, so a runtime only ever needs + one code path regardless of which version it was handed. + """ + loaded = load(document) + if isinstance(loaded, StrategyGraph): + return loaded.to_module() + return loaded diff --git a/nano/ir/graph.py b/nano/ir/graph.py index 8878f1c..84f9e76 100644 --- a/nano/ir/graph.py +++ b/nano/ir/graph.py @@ -1,20 +1,29 @@ -"""StrategyGraph — the loadable, validated unit of Nano IR. +"""StrategyGraph — the loadable, validated unit of baseline (v0.1.0) Nano IR. Load-time validation is the security boundary: manifest violations and unknown node types are rejected here, never discovered at runtime. + +This loader is pinned to `0.1.0` on purpose. It is the *reference* shape — one +flat schedule, conditions, intents, agents — and the semantics every richer +version has to agree with. v1.0.0 documents load through ``nano/ir/module.py``, +and a graph read here lifts into that module via ``to_module()``, so both +versions share a single evaluator and cannot drift apart in behavior. """ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Mapping, Optional, Tuple +from typing import TYPE_CHECKING, Any, Mapping, Optional, Tuple + +if TYPE_CHECKING: # pragma: no cover - import cycle avoidance, see to_module() + from .module import NanoModule from .nodes import AgentNode, ConditionNode, IntentNode, ScheduleNode from .schema import ( IRValidationError, KNOWN_EFFECTS, ManifestViolation, - NANO_IR_VERSION, + NANO_IR_VERSION_BASELINE, NODE_TYPES, ) @@ -40,9 +49,11 @@ def from_dict(data: Mapping[str, Any]) -> "StrategyGraph": if data.get("type") != "Strategy": raise IRValidationError("Root 'type' must be 'Strategy'") version = data.get("nanoIrVersion") - if version != NANO_IR_VERSION: + if version != NANO_IR_VERSION_BASELINE: raise IRValidationError( - f"nanoIrVersion {version!r} unsupported (expected {NANO_IR_VERSION!r})" + f"nanoIrVersion {version!r} unsupported " + f"(expected {NANO_IR_VERSION_BASELINE!r}; load 1.0.0 documents " + "with nano.ir.module.NanoModule)" ) name = data.get("name") if not isinstance(name, str) or not name: @@ -103,8 +114,94 @@ def to_dict(self) -> dict: nodes.extend(a.to_dict() for a in self.agents) return { "type": "Strategy", - "nanoIrVersion": NANO_IR_VERSION, + "nanoIrVersion": NANO_IR_VERSION_BASELINE, "name": self.name, "effects": list(self.effects), "nodes": nodes, } + + def to_module(self) -> "NanoModule": + """Lift this baseline graph into an equivalent v1.0 module. + + This exists so there is one evaluator rather than two. Baseline semantics + are reproduced exactly, including the detail that a graph with no + conditions emits nothing: the reference interpreter guards intent + emission on `all_true and graph.conditions`, so a schedule with an empty + body produces a schedule node and no rule. + + `tests/test_conformance.py` asserts this lift agrees with + ``nano/runtime/interpreter.py`` bar for bar across the whole corpus. That + test is the reason the two versions cannot drift. + """ + # Imported here, not at module scope: `module` imports this file's schema + # constants, and a top-level import back would be a cycle. + from .module import COMPARISON_OPS, IRNode, NanoModule + + nodes: list[IRNode] = [] + counter = 0 + + def emit(op: str, **kwargs) -> str: + nonlocal counter + counter += 1 + node_id = f"n{counter}" + nodes.append(IRNode(id=node_id, op=op, **kwargs)) + return node_id + + schedule_id: Optional[str] = None + if self.schedule is not None: + schedule_id = emit( + "schedule", attrs={"interval": self.schedule.interval} + ) + + signals: dict[str, str] = {} + condition_ids: list[str] = [] + for condition in self.conditions: + if condition.signal not in signals: + signals[condition.signal] = emit( + "feed.signal", + attrs={"name": condition.signal}, + type="series", + ) + value_id = emit( + "const", attrs={"value": condition.value}, type="float" + ) + condition_ids.append( + emit( + COMPARISON_OPS[condition.operator], + inputs=(signals[condition.signal], value_id), + type="series", + ) + ) + + entries: list[str] = [] + if schedule_id is not None and condition_ids: + guard = condition_ids[0] + for extra in condition_ids[1:]: + guard = emit( + "logic.and", inputs=(guard, extra), type="series" + ) + intent_ids: list[str] = [] + for intent in self.intents: + attrs: dict = {"action": intent.action} + if intent.asset is not None: + attrs["asset"] = intent.asset + if intent.confidence is not None: + attrs["confidence"] = intent.confidence + intent_ids.append(emit("intent.emit", attrs=attrs)) + block_id = emit("block", inputs=tuple(intent_ids)) + entries.append(emit("rule", inputs=(schedule_id, guard, block_id))) + + for agent in self.agents: + emit("agent", attrs={"name": agent.name}) + + return NanoModule( + name=self.name, + tier="nano", + effects=self.effects, + nodes=tuple(nodes), + entries=tuple(entries), + inputs=(), + signals=tuple(signals), + warmup=0, + provenance={"liftedFrom": NANO_IR_VERSION_BASELINE}, + ) diff --git a/nano/ir/module.py b/nano/ir/module.py new file mode 100644 index 0000000..2f62741 --- /dev/null +++ b/nano/ir/module.py @@ -0,0 +1,594 @@ +"""NanoModule — the v1.0.0 IR document, and the canonical executable form. + +Where baseline IR is a flat bag of typed records, v1.0 is a real **DAG**: every +node has an id, an opcode, typed operand references, and a compile-time +attribute dictionary. That shape is what makes the rest of the platform possible +— a graph can be rendered, partitioned, indexed, diffed, and content-addressed; +a bag of records can only be replayed. + +Three properties are enforced at load time, not discovered at run time: + +**References point backwards.** A node may only name ids already defined above +it. Cycles and forward references are rejected here, so no evaluator needs a +cycle check and none can loop forever. + +**Effects are a capability grant.** An `intent.emit` node in a module that never +declared `intent.emit` is a load-time rejection. Same for `llmre.escalate` and +`ai.infer`. A runtime bug above this layer cannot manufacture a capability the +module did not ask for. + +**Tier gates constructs.** A `tier nano` module containing a reasoning call is +rejected, so the entry language stays small and an auditor reading `tier nano` +knows there is no model in the loop. + +`moduleHash` is a content address over the canonical form with the hash field +itself removed — a document cannot commit to its own digest. `sourceHash` is +separate and covers the `.nano` text, so "same source" and "same compiled graph" +stay independently checkable: a comment-only edit changes one and not the other. + +Baseline documents are not a second dialect to maintain. ``StrategyGraph`` lifts +into a NanoModule (see ``nano/ir/graph.py``), the VM evaluates modules only, and +a conformance test asserts the two paths agree bar for bar. One evaluator, two +front doors. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from typing import Any, Dict, Mapping, Optional, Sequence, Set, Tuple + +from .schema import ( + AGENT_ROLES, + EFFECT_ORDER, + INTENT_ACTIONS, + IRValidationError, + KNOWN_EFFECTS, + ManifestViolation, + NANO_IR_VERSION_1_0, + RISK_LIMITS, + TIER_REQUIREMENTS, + TIERS, + TierViolation, +) + +COMPILER_NAME = "nnc" +COMPILER_VERSION = "1.0.0" + +# The determinism contract every module this compiler emits must satisfy. A +# runtime that cannot honour it is expected to refuse the module rather than +# execute it approximately. +DETERMINISM_CONTRACT: Mapping[str, Any] = { + "clock": "injected", + "entropy": "injected", + "fastmath": False, +} + + +@dataclass(frozen=True) +class OpSpec: + """What one opcode requires: operand count, capability, and tier.""" + + min_inputs: int = 0 + max_inputs: Optional[int] = 0 + effect: Optional[str] = None + construct: Optional[str] = None + + +# `max_inputs=None` means variadic. `construct` keys into TIER_REQUIREMENTS. +OPS: Mapping[str, OpSpec] = { + # data sources + "input.ref": OpSpec(), + "param.ref": OpSpec(), + "feed.signal": OpSpec(), + "const": OpSpec(), + "builtin.confidence": OpSpec(), + # derivation + "let": OpSpec(min_inputs=1, max_inputs=1), + "series.index": OpSpec(min_inputs=1, max_inputs=1), + "record.field": OpSpec(min_inputs=1, max_inputs=1), + "indicator": OpSpec(min_inputs=0, max_inputs=None), + # arithmetic and logic + "arith.add": OpSpec(min_inputs=2, max_inputs=2), + "arith.sub": OpSpec(min_inputs=2, max_inputs=2), + "arith.mul": OpSpec(min_inputs=2, max_inputs=2), + "arith.div": OpSpec(min_inputs=2, max_inputs=2), + "arith.mod": OpSpec(min_inputs=2, max_inputs=2), + "arith.neg": OpSpec(min_inputs=1, max_inputs=1), + "compare.lt": OpSpec(min_inputs=2, max_inputs=2), + "compare.le": OpSpec(min_inputs=2, max_inputs=2), + "compare.gt": OpSpec(min_inputs=2, max_inputs=2), + "compare.ge": OpSpec(min_inputs=2, max_inputs=2), + "compare.eq": OpSpec(min_inputs=2, max_inputs=2), + "compare.ne": OpSpec(min_inputs=2, max_inputs=2), + "logic.and": OpSpec(min_inputs=2, max_inputs=2), + "logic.or": OpSpec(min_inputs=2, max_inputs=2), + "logic.not": OpSpec(min_inputs=1, max_inputs=1), + # control flow + "schedule": OpSpec(), + "block": OpSpec(min_inputs=0, max_inputs=None), + # rule inputs are [schedule, condition, thenBlock] plus an optional + # elseBlock. Branches are real operands rather than ids buried in attrs, so + # the graph stays walkable by anything that understands `inputs`. + "rule": OpSpec(min_inputs=3, max_inputs=4), + # effects and declarations + "intent.emit": OpSpec(effect="intent.emit"), + "llmre.escalate": OpSpec(effect="llmre.escalate", construct="escalate"), + "ai.signature": OpSpec(construct="signature"), + "ai.infer": OpSpec( + min_inputs=0, max_inputs=None, effect="llm.call", construct="infer" + ), + "route": OpSpec(min_inputs=2, max_inputs=2, construct="route"), + "risk.limits": OpSpec(), + "agent": OpSpec(), +} + +# Operator spellings shared with codegen, so the two cannot disagree about what +# `<` or `+` lowers to. +COMPARISON_OPS: Mapping[str, str] = { + "<": "compare.lt", + "<=": "compare.le", + ">": "compare.gt", + ">=": "compare.ge", + "==": "compare.eq", + "!=": "compare.ne", +} + +ARITHMETIC_OPS: Mapping[str, str] = { + "+": "arith.add", + "-": "arith.sub", + "*": "arith.mul", + "/": "arith.div", + "%": "arith.mod", +} + + +@dataclass(frozen=True) +class IRNode: + """One node of the graph. Immutable, like everything downstream of the parser.""" + + id: str + op: str + inputs: Tuple[str, ...] = () + attrs: Mapping[str, Any] = field(default_factory=dict) + type: Optional[str] = None + + @staticmethod + def from_dict(data: Mapping[str, Any]) -> "IRNode": + node_id = data.get("id") + if not isinstance(node_id, str) or not node_id: + raise IRValidationError("Every node requires a non-empty 'id'") + op = data.get("op") + if op not in OPS: + raise IRValidationError(f"Unknown node op {op!r} in node {node_id!r}") + + inputs = data.get("inputs", []) + if not isinstance(inputs, list) or not all(isinstance(i, str) for i in inputs): + raise IRValidationError( + f"Node {node_id!r} 'inputs' must be a list of node ids" + ) + attrs = data.get("attrs", {}) + if not isinstance(attrs, dict): + raise IRValidationError(f"Node {node_id!r} 'attrs' must be an object") + + node_type = data.get("type") + if node_type is not None and not isinstance(node_type, str): + raise IRValidationError(f"Node {node_id!r} 'type' must be a string") + + spec = OPS[op] + count = len(inputs) + if count < spec.min_inputs or ( + spec.max_inputs is not None and count > spec.max_inputs + ): + if spec.max_inputs is None: + expected = f"at least {spec.min_inputs}" + elif spec.min_inputs == spec.max_inputs: + expected = str(spec.min_inputs) + else: + expected = f"{spec.min_inputs}..{spec.max_inputs}" + raise IRValidationError( + f"Node {node_id!r} ({op}) takes {expected} input(s), got {count}" + ) + + return IRNode( + id=node_id, op=op, inputs=tuple(inputs), attrs=dict(attrs), type=node_type + ) + + def to_dict(self) -> dict: + out: dict = {"id": self.id, "op": self.op, "inputs": list(self.inputs)} + if self.attrs: + out["attrs"] = dict(self.attrs) + if self.type is not None: + out["type"] = self.type + return out + + +@dataclass(frozen=True) +class ParamDecl: + name: str + type: str + value: Any + + def to_dict(self) -> dict: + return {"name": self.name, "type": self.type, "value": self.value} + + +@dataclass(frozen=True) +class InputDecl: + name: str + type: str + + def to_dict(self) -> dict: + return {"name": self.name, "type": self.type} + + +# --------------------------------------------------------------------------- +# small validation helpers +# --------------------------------------------------------------------------- + + +def _require_string_list(value: Any, what: str) -> Tuple[str, ...]: + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise IRValidationError(f"{what} must be a list of strings") + return tuple(value) + + +def _require_object_list(value: Any, what: str) -> Sequence[Mapping[str, Any]]: + if not isinstance(value, list) or not all(isinstance(v, Mapping) for v in value): + raise IRValidationError(f"{what} must be a list of objects") + return value + + +def _require_text(data: Mapping[str, Any], key: str, what: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not value: + raise IRValidationError(f"{what} requires a non-empty {key!r}") + return value + + +def _require_non_negative_int(value: Any, what: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise IRValidationError(f"{what} must be a non-negative integer") + return value + + +@dataclass(frozen=True) +class NanoModule: + """A validated v1.0.0 Nano IR document.""" + + name: str + tier: str + effects: Tuple[str, ...] + nodes: Tuple[IRNode, ...] + entries: Tuple[str, ...] + params: Tuple[ParamDecl, ...] = () + inputs: Tuple[InputDecl, ...] = () + signals: Tuple[str, ...] = () + warmup: int = 0 + determinism: Mapping[str, Any] = field( + default_factory=lambda: dict(DETERMINISM_CONTRACT) + ) + provenance: Mapping[str, Any] = field(default_factory=dict) + + # -- lookup ------------------------------------------------------------ + + def index(self) -> Dict[str, IRNode]: + """Every node by id. Build once and reuse when walking the graph.""" + return {node.id: node for node in self.nodes} + + def node(self, node_id: str) -> IRNode: + for candidate in self.nodes: + if candidate.id == node_id: + return candidate + raise KeyError(node_id) + + def of_op(self, op: str) -> Tuple[IRNode, ...]: + return tuple(node for node in self.nodes if node.op == op) + + @property + def source_hash(self) -> Optional[str]: + value = self.provenance.get("sourceHash") + return value if isinstance(value, str) else None + + # -- load -------------------------------------------------------------- + + @staticmethod + def from_dict(data: Mapping[str, Any]) -> "NanoModule": + if data.get("type") != "Strategy": + raise IRValidationError("Root 'type' must be 'Strategy'") + version = data.get("nanoIrVersion") + if version != NANO_IR_VERSION_1_0: + raise IRValidationError( + f"nanoIrVersion {version!r} unsupported by NanoModule " + f"(expected {NANO_IR_VERSION_1_0!r}; load 0.1.0 documents with " + "nano.ir.graph.StrategyGraph)" + ) + name = _require_text(data, "name", "Strategy") + + tier = data.get("tier", "nano") + if tier not in TIERS: + raise IRValidationError( + f"Unknown tier {tier!r} (expected one of {', '.join(TIERS)})" + ) + + effects_raw = data.get("effects") + if not isinstance(effects_raw, list) or not effects_raw: + raise IRValidationError("Strategy requires a non-empty 'effects' manifest") + unknown = set(effects_raw) - KNOWN_EFFECTS + if unknown: + raise IRValidationError(f"Unknown effects declared: {sorted(unknown)}") + effects = tuple(effects_raw) + + determinism = data.get("determinism", dict(DETERMINISM_CONTRACT)) + if not isinstance(determinism, dict): + raise IRValidationError("'determinism' must be an object") + if determinism.get("fastmath"): + # Result-changing float re-association makes replay a lottery. There + # is no flag to accept it, because a module whose numbers drift + # between runs cannot honour any of Nano's other guarantees. + raise IRValidationError( + "fastmath is not permitted: it breaks bit-identical replay" + ) + for key in ("clock", "entropy"): + if determinism.get(key, "injected") != "injected": + raise IRValidationError( + f"determinism.{key} must be 'injected' — Nano reads no ambient " + "clock or entropy" + ) + + nodes_raw = data.get("nodes") + if not isinstance(nodes_raw, list) or not nodes_raw: + raise IRValidationError("Strategy requires a non-empty 'nodes' list") + + seen: Set[str] = set() + nodes: list[IRNode] = [] + for node_data in nodes_raw: + if not isinstance(node_data, Mapping): + raise IRValidationError("Each node must be an object") + node = IRNode.from_dict(node_data) + if node.id in seen: + raise IRValidationError(f"Duplicate node id {node.id!r}") + for reference in node.inputs: + if reference not in seen: + raise IRValidationError( + f"Node {node.id!r} references {reference!r} before it is " + "defined (the graph must be acyclic and topologically " + "ordered)" + ) + spec = OPS[node.op] + if spec.effect is not None and spec.effect not in effects: + raise ManifestViolation( + f"Node {node.id!r} ({node.op}) needs effect {spec.effect!r}, " + "which the manifest does not declare" + ) + if spec.construct is not None: + required = TIER_REQUIREMENTS.get(spec.construct) + if required is not None and TIERS.index(tier) < TIERS.index(required): + raise TierViolation( + f"Node {node.id!r} ({node.op}) requires tier {required!r}, " + f"but the module declares {tier!r}" + ) + _validate_attrs(node) + seen.add(node.id) + nodes.append(node) + + entries = _require_string_list(data.get("entries", []), "'entries'") + for entry in entries: + if entry not in seen: + raise IRValidationError(f"Entry {entry!r} is not a declared node") + + params = tuple( + ParamDecl( + name=_require_text(p, "name", "Param"), + type=_require_text(p, "type", "Param"), + value=p.get("value"), + ) + for p in _require_object_list(data.get("params", []), "'params'") + ) + inputs = tuple( + InputDecl( + name=_require_text(i, "name", "Input"), + type=_require_text(i, "type", "Input"), + ) + for i in _require_object_list(data.get("inputs", []), "'inputs'") + ) + + provenance = data.get("provenance", {}) + if not isinstance(provenance, dict): + raise IRValidationError("'provenance' must be an object") + + return NanoModule( + name=name, + tier=tier, + effects=effects, + nodes=tuple(nodes), + entries=entries, + params=params, + inputs=inputs, + signals=_require_string_list(data.get("signals", []), "'signals'"), + warmup=_require_non_negative_int(data.get("warmup", 0), "'warmup'"), + determinism=dict(determinism), + provenance=dict(provenance), + ) + + # -- serialise --------------------------------------------------------- + + def to_dict(self, *, include_hash: bool = True) -> dict: + """The canonical document. Key order is fixed so output stays diffable.""" + out: dict = { + "type": "Strategy", + "nanoIrVersion": NANO_IR_VERSION_1_0, + "tier": self.tier, + "name": self.name, + "effects": list(self.effects), + "determinism": dict(self.determinism), + } + if self.params: + out["params"] = [p.to_dict() for p in self.params] + if self.inputs: + out["inputs"] = [i.to_dict() for i in self.inputs] + if self.signals: + out["signals"] = list(self.signals) + if self.warmup: + out["warmup"] = self.warmup + out["nodes"] = [n.to_dict() for n in self.nodes] + out["entries"] = list(self.entries) + if self.provenance: + out["provenance"] = dict(self.provenance) + if include_hash: + out["moduleHash"] = self.content_hash() + return out + + def content_hash(self) -> str: + """Content address of the executable graph. + + Two modules share a hash exactly when they would execute identically. That + property fixes what the digest may cover: the graph and its declarations, + and nothing else. + + Two exclusions follow from it. The hash field itself is out, because a + document cannot commit to its own digest. `provenance` is out too — it + records *where a module came from*, not what it does, and `sourceHash` + lives there. Including it would make a comment-only edit change the + module hash, collapsing the distinction between "the file was edited" and + "the behavior was edited" that having two hashes exists to draw. + """ + document = self.to_dict(include_hash=False) + document.pop("provenance", None) + canonical = json.dumps(document, sort_keys=True, separators=(",", ":")) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +# --------------------------------------------------------------------------- +# attribute validation +# --------------------------------------------------------------------------- + +_NAMED_OPS = frozenset( + {"input.ref", "param.ref", "feed.signal", "let", "agent"} +) + + +def _validate_attrs(node: IRNode) -> None: + """Check the attributes each opcode's behavior depends on. + + Only opcodes driven by an attribute are checked. An evaluator should never + have to ask whether an intent has an action. + """ + attrs = node.attrs + + if node.op == "schedule": + _require_text(attrs, "interval", f"Node {node.id!r} (schedule)") + return + + if node.op in _NAMED_OPS: + _require_text(attrs, "name", f"Node {node.id!r} ({node.op})") + if node.op == "agent": + role = attrs.get("role") + if role is not None and role not in AGENT_ROLES: + raise IRValidationError( + f"Node {node.id!r} declares unknown agent role {role!r}" + ) + return + + if node.op == "const": + if "value" not in attrs: + raise IRValidationError(f"Node {node.id!r} (const) requires a 'value'") + return + + if node.op == "series.index": + offset = attrs.get("offset") + if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0: + # The compiler cannot emit a negative offset, so reaching this means a + # hand-written or generated document is trying to read the future. + raise IRValidationError( + f"Node {node.id!r} (series.index) requires a non-negative integer " + "'offset' — offsets count backwards from the current bar" + ) + return + + if node.op == "record.field": + _require_text(attrs, "field", f"Node {node.id!r} (record.field)") + return + + if node.op == "indicator": + _require_text(attrs, "name", f"Node {node.id!r} (indicator)") + periods = attrs.get("periods", []) + if not isinstance(periods, list) or not all( + isinstance(p, int) and not isinstance(p, bool) and p >= 1 for p in periods + ): + raise IRValidationError( + f"Node {node.id!r} (indicator) 'periods' must be positive integers" + ) + _require_non_negative_int( + attrs.get("lookback", 0), f"Node {node.id!r} (indicator) 'lookback'" + ) + return + + if node.op == "intent.emit": + action = attrs.get("action") + if action not in INTENT_ACTIONS: + raise IRValidationError( + f"Node {node.id!r} declares intent action {action!r}, expected one " + f"of {sorted(INTENT_ACTIONS)}" + ) + asset = attrs.get("asset") + if asset is not None and (not isinstance(asset, str) or not asset): + raise IRValidationError( + f"Node {node.id!r} 'asset' must be a non-empty string" + ) + confidence = attrs.get("confidence") + if confidence is not None: + if not isinstance(confidence, (int, float)) or isinstance(confidence, bool): + raise IRValidationError( + f"Node {node.id!r} 'confidence' must be numeric" + ) + if not 0.0 <= float(confidence) <= 1.0: + raise IRValidationError( + f"Node {node.id!r} 'confidence' must be within [0, 1]" + ) + return + + if node.op == "llmre.escalate": + _require_text(attrs, "target", f"Node {node.id!r} (llmre.escalate)") + return + + if node.op == "ai.signature": + _require_text(attrs, "name", f"Node {node.id!r} (ai.signature)") + if not attrs.get("outputs"): + raise IRValidationError( + f"Node {node.id!r} (ai.signature) requires at least one output" + ) + return + + if node.op == "ai.infer": + _require_text(attrs, "signature", f"Node {node.id!r} (ai.infer)") + return + + if node.op == "route": + _require_text(attrs, "name", f"Node {node.id!r} (route)") + _require_text(attrs, "execute", f"Node {node.id!r} (route)") + return + + if node.op == "risk.limits": + limits = attrs.get("limits") + if not isinstance(limits, dict) or not limits: + raise IRValidationError( + f"Node {node.id!r} (risk.limits) requires a non-empty 'limits' object" + ) + for key, value in limits.items(): + if key not in RISK_LIMITS: + raise IRValidationError( + f"Node {node.id!r} declares unknown risk limit {key!r}" + ) + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise IRValidationError( + f"Node {node.id!r} risk limit {key!r} must be numeric" + ) + return + + +def canonical_effects(effects: Sequence[str]) -> Tuple[str, ...]: + """Order an effect manifest canonically, so two compiles are byte-comparable.""" + present = set(effects) + return tuple(effect for effect in EFFECT_ORDER if effect in present) diff --git a/nano/ir/schema.py b/nano/ir/schema.py index 8d3969e..e7909b2 100644 --- a/nano/ir/schema.py +++ b/nano/ir/schema.py @@ -2,21 +2,111 @@ The IR is the contract between the compiler and every runtime. No runtime executes `.nano` source directly — everything becomes this IR. + +## Two live IR versions + +`0.1.0` is the original flat strategy document: a `Schedule`, some `Condition`s, +some `Intent`s, some `Agent`s, and an effect manifest. `1.0.0` adds typed params +and inputs, computed series, explicit rules with `else` branches, risk limits, +reasoning signatures, and confidence routes. + +`NANO_IR_VERSION` still means `0.1.0` — the baseline. Ask for +`NANO_IR_VERSION_LATEST` when you want the newest version the compiler emits, +and `SUPPORTED_IR_VERSIONS` when you want everything a runtime will load. + +**The compiler emits the lowest version that can express the program.** A +strategy using only v0.1.0 features still compiles to byte-identical v0.1.0 IR, +which is why the whole example corpus and strategy library are untouched by +v1.0, and why a host pinned to `0.1.0` keeps working. Reach a v1.0 feature and +the document becomes `1.0.0`. `nano compile --ir-version` forces the choice when +a host wants one shape regardless of content. + +Runtimes load both. The v1.0 module (`nano/ir/module.py`) is the canonical +executable form and v0.1.0 graphs lift into it, so there is exactly one +evaluator and no chance of the two versions drifting apart in behavior. """ -NANO_IR_VERSION = "0.1.0" +NANO_IR_VERSION_BASELINE = "0.1.0" +NANO_IR_VERSION_1_0 = "1.0.0" + +# `NANO_IR_VERSION` keeps its original meaning — the baseline document version +# that `StrategyGraph` reads and writes. Adding a second version is not a reason +# to redefine a constant hosts already import; a name whose meaning shifts under +# a consumer is worse than a new name beside it. Use +# `NANO_IR_VERSION_LATEST` for "newest the compiler can emit". +NANO_IR_VERSION = NANO_IR_VERSION_BASELINE +NANO_IR_VERSION_LATEST = NANO_IR_VERSION_1_0 +# Every version a runtime in this package accepts, oldest first. +SUPPORTED_IR_VERSIONS = (NANO_IR_VERSION_BASELINE, NANO_IR_VERSION_1_0) + +# Node vocabulary of the baseline document. A `0.1.0` graph containing anything +# outside this set is rejected — the version is a promise about shape, and a +# loader that quietly accepted more would make the promise worthless. NODE_TYPES = frozenset({"Schedule", "Condition", "Intent", "Agent"}) CONDITION_OPERATORS = frozenset({"<", "<=", ">", ">=", "==", "!="}) +# Language tiers (locked platform spec §2). A module declares its tier and +# cannot use constructs from a higher one, which keeps the entry language small +# and the audit surface predictable. +TIERS = ("nano", "nano+", "nano++") + +# Constructs that require a tier above plain `nano`, and the tier each needs. +TIER_REQUIREMENTS = { + "signature": "nano+", + "route": "nano+", + "escalate": "nano+", + "infer": "nano+", +} + # Effect manifest vocabulary. Emitting an intent requires "intent.emit" in the # strategy's declared effects — this is the capability boundary: a graph that -# does not declare it cannot propose actions, regardless of its nodes. -KNOWN_EFFECTS = frozenset({"intent.emit", "log.append"}) +# does not declare it cannot propose actions, regardless of its nodes. The same +# rule extends to reasoning: a strategy that never declared `llmre.escalate` +# cannot hand a decision to a model, so escalation is rate-limitable and +# auditable exactly like any other side effect. +KNOWN_EFFECTS = frozenset( + { + "intent.emit", + "llm.call", + "llmre.escalate", + "log.append", + "sign.emit", + } +) + +# Canonical manifest order. Fixed rather than sorted, so a manifest is +# byte-stable and diffable between two compiles; the baseline pair +# ("intent.emit", "log.append") keeps its historical order. +EFFECT_ORDER = ("intent.emit", "llm.call", "llmre.escalate", "sign.emit", "log.append") INTENT_ACTIONS = frozenset({"BUY", "SELL", "EXECUTE", "PAUSE", "OBSERVE"}) +# Risk-limit vocabulary for the `risk { ... }` block. Each entry is +# (canonical unit, inclusive lower bound, inclusive upper bound or None). +# Fractions are fractions, never percentages: `max_daily_loss 0.02` is 2%. +# Accepting both conventions in one block is how a 2% stop becomes a 200% one. +RISK_LIMITS = { + "max_position_size": ("fraction of equity", 0.0, 1.0), + "max_daily_loss": ("fraction of equity", 0.0, 1.0), + "max_drawdown": ("fraction of equity", 0.0, 1.0), + "max_open_positions": ("count", 0, None), + "max_orders_per_day": ("count", 0, None), + "stop_trading_after_losses": ("consecutive losses", 1, None), + "min_confidence": ("confidence in [0, 1]", 0.0, 1.0), +} + +# Limits whose value must be a whole number. +INTEGER_RISK_LIMITS = frozenset( + {"max_open_positions", "max_orders_per_day", "stop_trading_after_losses"} +) + +# Agent roles. A role is routing metadata, not a capability: a `validation` +# agent may veto an escalation result, an `execution` agent is a named handoff +# target, and `research` is consulted about novel situations. +AGENT_ROLES = ("research", "validation", "execution", "observer") + class IRValidationError(ValueError): """The document is not valid Nano IR.""" @@ -24,3 +114,7 @@ class IRValidationError(ValueError): class ManifestViolation(IRValidationError): """A node requires an effect the manifest does not declare.""" + + +class TierViolation(IRValidationError): + """A module uses a construct its declared tier does not permit.""" diff --git a/nano/runtime/__init__.py b/nano/runtime/__init__.py index 01c66f1..39004e4 100644 --- a/nano/runtime/__init__.py +++ b/nano/runtime/__init__.py @@ -1,14 +1,30 @@ +"""Nano runtimes. + +``interpreter.execute`` is the reference semantics for baseline graphs and remains +the definition of correct behavior. ``vm.run_module`` executes v1.0 modules — +series, indicators, rules, routes, escalation — and is what every v1.0 consumer +uses. A conformance test holds the two to the same observable behavior on the +shared corpus, which is what keeps "the artifact that backtested is the artifact +that trades" true across an IR version boundary. +""" + from .effects import Intent, LogEntry from .interpreter import ExecutionResult, MarketFrame, RuntimeError_, execute from .scheduler import interval_seconds, ticks +from .vm import Escalation, ModuleResult, ReasoningProvider, run_frames, run_module __all__ = [ + "Escalation", "ExecutionResult", "Intent", "LogEntry", "MarketFrame", + "ModuleResult", + "ReasoningProvider", "RuntimeError_", "execute", "interval_seconds", + "run_frames", + "run_module", "ticks", ] diff --git a/nano/runtime/vm.py b/nano/runtime/vm.py new file mode 100644 index 0000000..73e7f0e --- /dev/null +++ b/nano/runtime/vm.py @@ -0,0 +1,548 @@ +"""The Nano VM — the single evaluator for compiled modules. + +Both IR versions run here. v1.0 modules arrive directly; baseline graphs arrive +via ``StrategyGraph.to_module()``. Having one evaluator is the point: two would +eventually disagree, and the disagreement would surface as a backtest that no +longer matches production. + +Evaluation is in two stages, and the split is what keeps it honest: + +**Stage 1 — derive every series once, over the whole frame.** Indicators, +arithmetic, comparisons, and offsets are computed for all bars up front. Each +kernel sees only the data it is entitled to (``nano/indicators/compute.py`` never +reads forward), and a warm-up bar yields absence rather than a fabricated number. + +**Stage 2 — walk the schedule and sample.** At each tick the rule's condition is +read *at that bar*. A condition that is absent — because something upstream has +not warmed up — is not true, so no intent is proposed from a bar whose inputs did +not exist. This is why `warmup` travels in the IR: a replay can report how many +bars it discarded instead of quietly counting them as no-signal. + +Purity is the contract the reference interpreter has always held: identical module +plus identical frame gives an identical result, bit for bit. No ambient clock, no +ambient randomness, no I/O. Reasoning calls are the one stochastic thing a module +can contain, and they do not break that — the provider is injected, and +``nano/agents/`` records results so a replay feeds back recorded data rather than +calling a model again. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ( + Any, + Dict, + List, + Mapping, + Optional, + Protocol, + Sequence, + Tuple, + Union, +) + +from ..indicators import evaluate as evaluate_indicator +from ..indicators.registry import lookup as lookup_indicator +from ..ir.module import IRNode, NanoModule +from .effects import Intent, LogEntry +from .interpreter import MarketFrame, RuntimeError_ +from .scheduler import ticks + +Cell = Optional[Union[float, bool]] +Series = Tuple[Any, ...] +# A node's value is either a whole series or a single compile-time scalar. +Value = Union[Series, float, int, bool, str, None] + + +class ReasoningProvider(Protocol): + """Supplies the result of one `infer` call against a declared signature. + + Implemented by a host (Aether Cloud's reasoning gateway, ATS's llmre) and, in + replay, by ``nano.agents.RecordedProvider``. The VM never constructs a + provider itself, so a module cannot reach a model the caller did not hand it. + """ + + def infer( + self, signature: str, inputs: Mapping[str, Cell], *, timestamp: int + ) -> Mapping[str, Cell]: ... + + +@dataclass(frozen=True) +class Escalation: + """A recorded hand-off back to a reasoning layer.""" + + target: str + timestamp: int + is_agent: bool + reason: str + + def to_dict(self) -> dict: + return { + "escalate": self.target, + "timestamp": self.timestamp, + "isAgent": self.is_agent, + "reason": self.reason, + } + + +@dataclass(frozen=True) +class ModuleResult: + """One run's complete account: intents, escalations, and the audit log.""" + + intents: Tuple[Intent, ...] + escalations: Tuple[Escalation, ...] + log: Tuple[LogEntry, ...] + warmup_bars_skipped: int = 0 + + def to_dict(self) -> dict: + return { + "intents": [i.to_dict() for i in self.intents], + "escalations": [e.to_dict() for e in self.escalations], + "log": [entry.to_dict() for entry in self.log], + "warmup_bars_skipped": self.warmup_bars_skipped, + } + + +# --------------------------------------------------------------------------- +# elementwise helpers +# --------------------------------------------------------------------------- + + +def _as_series(value: Value, length: int) -> Series: + """Broadcast a scalar across every bar; pass a series through unchanged.""" + if isinstance(value, tuple): + return value + return (value,) * length + + +def _zip_apply(left: Series, right: Series, fn) -> Series: + out: List[Cell] = [] + for index in range(min(len(left), len(right))): + a, b = left[index], right[index] + out.append(None if a is None or b is None else fn(a, b)) + return tuple(out) + + +def _map_apply(values: Series, fn) -> Series: + return tuple(None if v is None else fn(v) for v in values) + + +def _divide(a: Cell, b: Cell) -> Cell: + # A zero divisor yields absence rather than an exception. A strategy dividing + # by a series cannot know in advance that one bar will be zero, and aborting a + # whole backtest over one bar is worse than that bar having no value. + return None if float(b) == 0.0 else float(a) / float(b) + + +def _modulo(a: Cell, b: Cell) -> Cell: + return None if float(b) == 0.0 else float(a) % float(b) + + +_BINARY_KERNELS = { + "arith.add": lambda a, b: float(a) + float(b), + "arith.sub": lambda a, b: float(a) - float(b), + "arith.mul": lambda a, b: float(a) * float(b), + "arith.div": _divide, + "arith.mod": _modulo, + "compare.lt": lambda a, b: a < b, + "compare.le": lambda a, b: a <= b, + "compare.gt": lambda a, b: a > b, + "compare.ge": lambda a, b: a >= b, + "compare.eq": lambda a, b: a == b, + "compare.ne": lambda a, b: a != b, + "logic.and": lambda a, b: bool(a) and bool(b), + "logic.or": lambda a, b: bool(a) or bool(b), +} + + +def _field_of(cell: Any, name: str) -> Cell: + """Read one field from a reasoning result, tolerating absence.""" + if isinstance(cell, Mapping): + value = cell.get(name) + return value if value is None or isinstance(value, (int, float, bool)) else None + return None + + +# --------------------------------------------------------------------------- +# the machine +# --------------------------------------------------------------------------- + + +@dataclass +class _Machine: + module: NanoModule + frame: MarketFrame + provider: Optional[ReasoningProvider] = None + values: Dict[str, Value] = field(default_factory=dict) + log: List[LogEntry] = field(default_factory=list) + + def __post_init__(self) -> None: + self.length = len(self.frame.timestamps) + self.index = self.module.index() + self._params = {p.name: p.value for p in self.module.params} + self._signatures = { + node.attrs["name"]: node.attrs + for node in self.module.of_op("ai.signature") + } + + # -- stage 1: derive every series -------------------------------------- + + def derive(self) -> None: + """Evaluate every node once, in declaration order. + + Declaration order is a valid evaluation order because the loader proved + the graph is topologically sorted — every operand is already computed by + the time a node is reached, so there is no recursion and no cycle check. + """ + for node in self.module.nodes: + self.values[node.id] = self._evaluate(node) + + def _operands(self, node: IRNode) -> List[Value]: + return [self.values[i] for i in node.inputs] + + def _evaluate(self, node: IRNode) -> Value: + op = node.op + + if op == "const": + return node.attrs["value"] + if op == "param.ref": + return self._params.get(node.attrs["name"]) + if op in ("input.ref", "feed.signal"): + return self._read_frame(node.attrs["name"]) + if op == "builtin.confidence": + return self._confidence_series() + if op == "let": + return self._operands(node)[0] + + if op == "series.index": + target = _as_series(self._operands(node)[0], self.length) + offset = int(node.attrs["offset"]) + # Bars before the offset have no history to read, so they are absent + # rather than clamped to the first bar -- clamping would invent data. + return tuple( + target[i - offset] if i >= offset else None + for i in range(self.length) + ) + + if op == "indicator": + return self._evaluate_indicator(node) + + if op == "arith.neg": + return _map_apply( + _as_series(self._operands(node)[0], self.length), lambda v: -float(v) + ) + if op == "logic.not": + return _map_apply( + _as_series(self._operands(node)[0], self.length), + lambda v: not bool(v), + ) + if op in _BINARY_KERNELS: + left, right = self._operands(node) + return _zip_apply( + _as_series(left, self.length), + _as_series(right, self.length), + _BINARY_KERNELS[op], + ) + + if op == "ai.infer": + return self._evaluate_infer(node) + if op == "record.field": + return self._evaluate_record_field(node) + + # schedule / block / rule / route / intent.emit / llmre.escalate / agent / + # ai.signature / risk.limits carry no series value: they are control flow, + # effects, or declarations, and stage 2 handles them. + return None + + def _read_frame(self, name: str) -> Series: + if name not in self.frame.signals: + raise RuntimeError_(f"Signal {name!r} not present in market frame") + return tuple( + None if v is None else float(v) for v in self.frame.signals[name] + ) + + def _confidence_series(self) -> Series: + """Resolve the `confidence` builtin for every bar. + + Confidence is *injected*, like time and entropy. Three sources, in order: + a `confidence` series on the frame; the `confidence` output of a reasoning + call if the module makes one; then 1.0 for a module that never expresses + doubt. Nothing here samples an ambient value. + """ + if "confidence" in self.frame.signals: + return self._read_frame("confidence") + for node in self.module.of_op("ai.infer"): + resolved = self.values.get(node.id) + if isinstance(resolved, tuple): + return tuple(_field_of(cell, "confidence") for cell in resolved) + return (1.0,) * self.length + + def _evaluate_indicator(self, node: IRNode) -> Series: + name = node.attrs["name"] + spec = lookup_indicator(name) + if spec is None: # pragma: no cover - the loader validated the name + raise RuntimeError_(f"Unknown indicator {name!r}") + + operands = [_as_series(v, self.length) for v in self._operands(node)] + + # Rebuild the original argument order: periods were folded into attrs at + # compile time, so they are spliced back into their declared positions. + arguments: List[Any] = [] + period_positions = set(spec.period_indices) + operand_iter = iter(operands) + period_iter = iter(list(node.attrs.get("periods", []))) + for position in range(spec.arity): + if position in period_positions: + arguments.append(next(period_iter)) + else: + arguments.append(next(operand_iter)) + return evaluate_indicator(name, arguments, length=self.length) + + def _evaluate_infer(self, node: IRNode) -> Series: + """Call the injected provider once per bar and keep the whole result. + + With no provider the result is absent for every bar. That is deliberate: a + module that needs reasoning and was given none should produce no signal, + not a default one. + """ + signature = node.attrs["signature"] + first_timestamp = self.frame.timestamps[0] if self.frame.timestamps else 0 + + if self.provider is None: + self.log.append( + LogEntry( + event="infer.skipped", + timestamp=first_timestamp, + detail=f"{signature}: no reasoning provider supplied", + ) + ) + return (None,) * self.length + + declared = self._signatures.get(signature, {}) + names = [f["name"] for f in declared.get("inputs", [])] + operands = [_as_series(v, self.length) for v in self._operands(node)] + + out: List[Any] = [] + for bar in range(self.length): + payload = { + name: (operands[position][bar] if position < len(operands) else None) + for position, name in enumerate(names) + } + result = self.provider.infer( + signature, payload, timestamp=self.frame.timestamps[bar] + ) + out.append(dict(result)) + self.log.append( + LogEntry( + event="infer.called", + timestamp=self.frame.timestamps[bar], + detail=f"{signature} -> {sorted(result)}", + ) + ) + return tuple(out) + + def _evaluate_record_field(self, node: IRNode) -> Series: + field_name = node.attrs["field"] + target = _as_series(self._operands(node)[0], self.length) + return tuple(_field_of(cell, field_name) for cell in target) + + # -- stage 2: walk the schedule ---------------------------------------- + + def run(self) -> ModuleResult: + self.log.append( + LogEntry( + event="module.loaded", + timestamp=self.frame.timestamps[0] if self.frame.timestamps else 0, + detail=( + f"{self.module.name} tier={self.module.tier} " + f"effects={list(self.module.effects)} " + f"warmup={self.module.warmup}" + ), + ) + ) + self.derive() + + intents: List[Intent] = [] + escalations: List[Escalation] = [] + skipped = 0 + + for entry in self.module.entries: + node = self.index[entry] + if node.op == "rule": + skipped += self._run_rule(node, intents, escalations) + elif node.op == "route": + self._run_route(node, escalations) + + return ModuleResult( + intents=tuple(intents), + escalations=tuple(escalations), + log=tuple(self.log), + warmup_bars_skipped=skipped, + ) + + def _run_rule( + self, node: IRNode, intents: List[Intent], escalations: List[Escalation] + ) -> int: + schedule_id, condition_id, then_id = node.inputs[0], node.inputs[1], node.inputs[2] + else_id = node.inputs[3] if len(node.inputs) > 3 else None + + interval = self.index[schedule_id].attrs["interval"] + condition = _as_series(self.values.get(condition_id), self.length) + skipped = 0 + + for bar, timestamp in ticks(interval, self.frame.timestamps): + observed = condition[bar] if bar < len(condition) else None + if observed is None: + # Absent, not false: something upstream has not warmed up. Counted + # so a replay can report how many bars it discarded. + skipped += 1 + self.log.append( + LogEntry( + event="condition.unwarmed", + timestamp=timestamp, + detail=f"{condition_id} has no value at bar {bar}", + ) + ) + continue + + passed = bool(observed) + self.log.append( + LogEntry( + event="condition.evaluated", + timestamp=timestamp, + detail=f"{condition_id} -> {passed}", + ) + ) + branch = then_id if passed else else_id + if branch is not None: + self._run_block(branch, timestamp, bar, intents, escalations) + return skipped + + def _run_block( + self, + block_id: str, + timestamp: int, + bar: int, + intents: List[Intent], + escalations: List[Escalation], + ) -> None: + for statement_id in self.index[block_id].inputs: + statement = self.index[statement_id] + + if statement.op == "intent.emit": + intent = Intent( + action=statement.attrs["action"], + timestamp=timestamp, + asset=statement.attrs.get("asset"), + confidence=statement.attrs.get("confidence"), + ) + intents.append(intent) + self.log.append( + LogEntry( + event="intent.emitted", + timestamp=timestamp, + detail=f"{intent.action} asset={intent.asset}", + ) + ) + continue + + if statement.op == "llmre.escalate": + escalations.append( + self._escalate(statement, timestamp, reason="rule body") + ) + continue + + if statement.op == "rule": + # A nested rule shares the enclosing schedule, so it is evaluated + # at this bar rather than re-walked over the whole timeline. + self._run_nested_rule(statement, timestamp, bar, intents, escalations) + + def _run_nested_rule( + self, + node: IRNode, + timestamp: int, + bar: int, + intents: List[Intent], + escalations: List[Escalation], + ) -> None: + condition = _as_series(self.values.get(node.inputs[1]), self.length) + observed = condition[bar] if bar < len(condition) else None + if observed is None: + return + if bool(observed): + branch: Optional[str] = node.inputs[2] + else: + branch = node.inputs[3] if len(node.inputs) > 3 else None + if branch is not None: + self._run_block(branch, timestamp, bar, intents, escalations) + + def _run_route(self, node: IRNode, escalations: List[Escalation]) -> None: + """Evaluate a confidence route over every bar of the frame. + + A route has no schedule of its own: it guards a named execution path, so + it is checked at every bar and escalates on the bars where the guard does + not hold. + """ + condition = _as_series(self.values.get(node.inputs[0]), self.length) + escalate_node = self.index[node.inputs[1]] + name = node.attrs["name"] + + for bar, timestamp in enumerate(self.frame.timestamps): + observed = condition[bar] if bar < len(condition) else None + if observed is None or not bool(observed): + reason = ( + f"route {name}: guard not satisfied" + if observed is not None + else f"route {name}: guard has no value" + ) + escalations.append( + self._escalate(escalate_node, timestamp, reason=reason) + ) + continue + self.log.append( + LogEntry( + event="route.executed", + timestamp=timestamp, + detail=f"{name} -> {node.attrs['execute']}", + ) + ) + + def _escalate(self, node: IRNode, timestamp: int, *, reason: str) -> Escalation: + escalation = Escalation( + target=node.attrs["target"], + timestamp=timestamp, + is_agent=bool(node.attrs.get("isAgent", False)), + reason=reason, + ) + self.log.append( + LogEntry( + event="llmre.escalated", + timestamp=timestamp, + detail=f"{escalation.target}: {reason}", + ) + ) + return escalation + + +def run_module( + module: NanoModule, + frame: MarketFrame, + *, + provider: Optional[ReasoningProvider] = None, +) -> ModuleResult: + """Execute `module` over `frame`; return intents, escalations, and the log. + + Pure with no provider, and pure *with* one whenever the provider is — which is + what ``nano.agents.RecordedProvider`` guarantees for replay. + """ + return _Machine(module=module, frame=frame, provider=provider).run() + + +def run_frames( + module: NanoModule, + frames: Sequence[MarketFrame], + *, + provider: Optional[ReasoningProvider] = None, +) -> Tuple[ModuleResult, ...]: + """Execute `module` over several frames in order.""" + return tuple(run_module(module, frame, provider=provider) for frame in frames) diff --git a/nano/types/__init__.py b/nano/types/__init__.py new file mode 100644 index 0000000..e908262 --- /dev/null +++ b/nano/types/__init__.py @@ -0,0 +1,74 @@ +"""Nano's type system and semantic analysis. + +Four modules, in dependency order: + +* ``kinds`` — the type vocabulary and its unification rules. +* ``env`` — the symbol table: what a name is, and what it is allowed to do. +* ``lookahead`` — compile-time integer folding plus series-offset and period + validation. The module that makes reading the future unrepresentable. +* ``checker`` — the pass that ties them together into a ``TypedProgram``. + +Importing this package pulls in no runtime machinery: analysis is a pure +function from a parsed AST to a typed program, with no I/O anywhere in it. +""" + +from ..compiler.errors import LookaheadError, NanoCompileError, NanoTypeError +from .checker import ( + Resolution, + ResolvedFeed, + ResolvedIndicator, + ResolvedInfer, + TypedProgram, + check, +) +from .env import Scope, Symbol +from .kinds import ( + BOOL, + CONFIDENCE, + DURATION, + FLOAT, + INT, + SERIES_BOOL, + SERIES_FLOAT, + STRING, + VOID, + Type, + is_assignable, + parse_type, + record, + series, + type_names, +) +from .lookahead import fold_int, resolve_offset, resolve_period + +__all__ = [ + "BOOL", + "CONFIDENCE", + "DURATION", + "FLOAT", + "INT", + "LookaheadError", + "NanoCompileError", + "NanoTypeError", + "Resolution", + "ResolvedFeed", + "ResolvedIndicator", + "ResolvedInfer", + "SERIES_BOOL", + "SERIES_FLOAT", + "STRING", + "Scope", + "Symbol", + "Type", + "TypedProgram", + "VOID", + "check", + "fold_int", + "is_assignable", + "parse_type", + "record", + "resolve_offset", + "resolve_period", + "series", + "type_names", +] diff --git a/nano/types/checker.py b/nano/types/checker.py new file mode 100644 index 0000000..b123cac --- /dev/null +++ b/nano/types/checker.py @@ -0,0 +1,945 @@ +"""Semantic analysis: types, arity, tiers, effects, warm-up, look-ahead. + +The parser proves a program is *well-formed*. This pass proves it is +*meaningful* — that every name resolves, every operator has operands it accepts, +every indicator period is knowable before data arrives, and no expression can +read the future. It is also where the effect manifest is derived, because what a +program may do should follow from what it actually does, not from a list the +author maintains by hand. + +Analysis stops at the first error, with an exact position. Nano's compile errors +have always been single and precise, and a half-typed tree produces cascades of +invented follow-on errors that are worse than no report at all. + +Two design points worth stating plainly: + +**Feed signals are series.** `RSI` in `RSI < 30` types as `series`, not +`float`. The comparison therefore yields `series`, which a condition +position samples at the current bar. That reproduces v0.1.0 semantics exactly +while making `RSI[1]` mean something — history was always in the frame; the type +system just now admits it. + +**Expression types are keyed by object identity.** `TypedProgram` holds the +`StrategyAst` it analysed, so every node stays alive and its `id()` stays valid +for the program's lifetime. Structural keying would collide: `RSI < 30` appearing +in two rules is two nodes with one spelling, and they can carry different +warm-up. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Mapping, Optional, Set, Tuple, Union + +from ..compiler.ast import ( + ActionAst, + Binary, + BoolLit, + Call, + DurationLit, + EscalateStmt, + Expr, + IfStmt, + Index, + Member, + Name, + NumberLit, + RuleAst, + SignatureAst, + Stmt, + StrategyAst, + StringLit, + Unary, +) +from ..compiler.errors import NanoTypeError +from ..indicators.registry import IndicatorSpec, lookup as lookup_indicator +from ..ir.schema import ( + AGENT_ROLES, + EFFECT_ORDER, + INTEGER_RISK_LIMITS, + INTENT_ACTIONS, + RISK_LIMITS, + TIER_REQUIREMENTS, + TIERS, +) +from .env import ( + KIND_AGENT, + KIND_BUILTIN, + KIND_FEED, + KIND_INPUT, + KIND_LET, + KIND_PARAM, + KIND_ROUTE, + KIND_SIGNATURE, + Scope, + Symbol, +) +from .kinds import ( + BOOL, + CONFIDENCE, + DURATION, + FLOAT, + INT, + SERIES_FLOAT, + STRING, + Type, + is_assignable, + lift, + parse_type, + record, + series, + unify_comparison, + unify_numeric, +) +from .lookahead import fold_int, resolve_offset, resolve_period + +_ARITHMETIC_OPS = frozenset({"+", "-", "*", "/", "%"}) +_COMPARISON_OPS = frozenset({"<", "<=", ">", ">=", "==", "!="}) +_LOGICAL_OPS = frozenset({"and", "or"}) + +# `confidence` reads the confidence attached to the decision being evaluated: +# the most recent `infer` result if there is one, otherwise the confidence +# declared on the intent under consideration. It exists so +# `if confidence < 0.6 { escalate "research" }` says what it appears to say. +_BUILTINS: Tuple[Tuple[str, Type], ...] = (("confidence", CONFIDENCE),) + + +# --------------------------------------------------------------------------- +# resolutions — what the checker learned about each call site +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ResolvedFeed: + """This name or call is a host-supplied signal, not something Nano computes.""" + + signal: str + + +@dataclass(frozen=True) +class ResolvedIndicator: + """This call is computed by ``nano/indicators``, with periods already resolved.""" + + name: str + spec: IndicatorSpec + periods: Tuple[int, ...] + lookback: int + lifted: bool + + +@dataclass(frozen=True) +class ResolvedInfer: + """This call hands typed inputs to a reasoning model through a signature.""" + + signature: str + + +Resolution = Union[ResolvedFeed, ResolvedIndicator, ResolvedInfer] + + +@dataclass(frozen=True) +class TypedProgram: + """A strategy that passed semantic analysis, plus everything lowering needs.""" + + strategy: StrategyAst + tier: str + symbols: Mapping[str, Symbol] + expr_types: Mapping[int, Type] + resolutions: Mapping[int, Resolution] + effects: Tuple[str, ...] + warmup: int + strict: bool + + def type_of(self, expr: Expr) -> Optional[Type]: + return self.expr_types.get(id(expr)) + + def resolution_of(self, expr: Expr) -> Optional[Resolution]: + return self.resolutions.get(id(expr)) + + def of_kind(self, kind: str) -> Tuple[Symbol, ...]: + return tuple(s for s in self.symbols.values() if s.kind == kind) + + @property + def feed_signals(self) -> Tuple[str, ...]: + """Signal names the host must supply, in first-appearance order.""" + return tuple(s.name for s in self.of_kind(KIND_FEED)) + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _is_plain_int(value: object) -> bool: + """True for a real integer. `bool` is an int in Python and is not one here.""" + return isinstance(value, int) and not isinstance(value, bool) + + +def _literal_type(value: object) -> Optional[Type]: + if isinstance(value, bool): + return BOOL + if isinstance(value, int): + return INT + if isinstance(value, float): + return FLOAT + if isinstance(value, str): + return STRING + return None + + +# --------------------------------------------------------------------------- +# the checker +# --------------------------------------------------------------------------- + + +class _Checker: + def __init__(self, strategy: StrategyAst) -> None: + self.strategy = strategy + # Declaring an input is the author saying "this is my data" — from that + # point an unknown bare name is a typo, not a signal. See env.Scope. + self.scope = Scope(strict=bool(strategy.inputs)) + self.expr_types: Dict[int, Type] = {} + self.resolutions: Dict[int, Resolution] = {} + self.signatures: Dict[str, SignatureAst] = {} + self.effects: Set[str] = {"log.append"} + self.warmup = 0 + + # -- entry ------------------------------------------------------------- + + def run(self) -> TypedProgram: + self._check_tier() + for name, type_ in _BUILTINS: + self.scope.declare(Symbol(name=name, type=type_, kind=KIND_BUILTIN)) + + self._declare_signatures() + self._declare_params() + self._declare_inputs() + self._declare_agents() + self._declare_lets() + self._check_risk() + self._check_routes() + self._check_schedules() + + return TypedProgram( + strategy=self.strategy, + tier=self.strategy.tier, + symbols=self.scope.snapshot(), + expr_types=dict(self.expr_types), + resolutions=dict(self.resolutions), + effects=tuple(e for e in EFFECT_ORDER if e in self.effects), + warmup=self.warmup, + strict=self.scope.strict, + ) + + # -- diagnostics ------------------------------------------------------- + + @staticmethod + def _fail(message: str, line: int, column: int) -> NanoTypeError: + return NanoTypeError(message, line, column) + + def _declare(self, symbol: Symbol) -> None: + clash = self.scope.declare(symbol) + if clash is not None: + where = f" on line {clash.line}" if clash.line else "" + raise self._fail( + f"{symbol.name!r} is already declared as a {clash.kind}{where}", + symbol.line, + symbol.column, + ) + + def _resolve_annotation(self, text: str, line: int, column: int) -> Type: + parsed = parse_type(text) + if parsed is None: + raise self._fail(f"Unknown type {text!r}", line, column) + return parsed + + # -- tier -------------------------------------------------------------- + + def _check_tier(self) -> None: + if self.strategy.tier not in TIERS: + raise self._fail( + f"Unknown tier {self.strategy.tier!r} " + f"(expected one of {', '.join(TIERS)})", + self.strategy.line, + self.strategy.column, + ) + + def _require_tier(self, construct: str, line: int, column: int) -> None: + """Reject a construct the declared tier does not reach.""" + required = TIER_REQUIREMENTS.get(construct) + if required is None: + return + if TIERS.index(self.strategy.tier) < TIERS.index(required): + raise self._fail( + f"{construct!r} requires tier {required!r}, but this module " + f"declares tier {self.strategy.tier!r} " + f"(add `tier {required}` above the strategy)", + line, + column, + ) + + # -- declarations ------------------------------------------------------ + + def _declare_signatures(self) -> None: + for signature in self.strategy.signatures: + self._require_tier("signature", signature.line, signature.column) + seen: Set[str] = set() + outputs: list[Tuple[str, Type]] = [] + output_names = {field.name for field in signature.outputs} + + for field in (*signature.inputs, *signature.outputs): + if field.name in seen: + raise self._fail( + f"Signature {signature.name!r} declares " + f"{field.name!r} twice", + field.line, + field.column, + ) + seen.add(field.name) + field_type = self._resolve_annotation( + field.declared_type, field.line, field.column + ) + if ( + field.low is not None + and field.high is not None + and field.low > field.high + ): + raise self._fail( + f"Range [{field.low}, {field.high}] on {field.name!r} " + "is empty", + field.line, + field.column, + ) + if field.name in output_names: + outputs.append((field.name, field_type)) + + self.signatures[signature.name] = signature + self._declare( + Symbol( + name=signature.name, + type=record(signature.name), + kind=KIND_SIGNATURE, + line=signature.line, + column=signature.column, + fields=tuple(outputs), + ) + ) + + def _declare_params(self) -> None: + for param in self.strategy.params: + inferred = _literal_type(param.value) + if inferred is None: + raise self._fail( + f"Param {param.name!r} has an unsupported literal", + param.line, + param.column, + ) + declared = ( + self._resolve_annotation(param.declared_type, param.line, param.column) + if param.declared_type is not None + else inferred + ) + if declared.is_series: + raise self._fail( + f"Param {param.name!r} cannot be a series — params are " + "compile-time constants, and a series is runtime data", + param.line, + param.column, + ) + if not is_assignable(inferred, declared): + raise self._fail( + f"Param {param.name!r} is declared {declared} but its " + f"default is {inferred}", + param.line, + param.column, + ) + self._declare( + Symbol( + name=param.name, + type=declared, + kind=KIND_PARAM, + line=param.line, + column=param.column, + const_value=param.value, + ) + ) + + def _declare_inputs(self) -> None: + for declared_input in self.strategy.inputs: + input_type = self._resolve_annotation( + declared_input.declared_type, + declared_input.line, + declared_input.column, + ) + self._declare( + Symbol( + name=declared_input.name, + type=input_type, + kind=KIND_INPUT, + line=declared_input.line, + column=declared_input.column, + ) + ) + + def _declare_agents(self) -> None: + for agent in self.strategy.agents: + if agent.role is not None and agent.role not in AGENT_ROLES: + raise self._fail( + f"Unknown agent role {agent.role!r} " + f"(expected one of {', '.join(AGENT_ROLES)})", + agent.line, + agent.column, + ) + self._declare( + Symbol( + name=agent.name, + type=STRING, + kind=KIND_AGENT, + line=agent.line, + column=agent.column, + const_value=agent.role, + ) + ) + + def _declare_lets(self) -> None: + """Bind derived values in source order — a `let` may use earlier ones.""" + for binding in self.strategy.lets: + value_type, lookback = self._visit(binding.expr) + if binding.declared_type is not None: + declared = self._resolve_annotation( + binding.declared_type, binding.line, binding.column + ) + if not is_assignable(value_type, declared): + raise self._fail( + f"{binding.name!r} is declared {declared} but its value " + f"is {value_type}", + binding.line, + binding.column, + ) + value_type = declared + self._declare( + Symbol( + name=binding.name, + type=value_type, + kind=KIND_LET, + line=binding.line, + column=binding.column, + lookback=lookback, + ) + ) + self.warmup = max(self.warmup, lookback) + + # -- risk -------------------------------------------------------------- + + def _check_risk(self) -> None: + if self.strategy.risk is None: + return + seen: Set[str] = set() + for limit in self.strategy.risk.limits: + spec = RISK_LIMITS.get(limit.name) + if spec is None: + raise self._fail( + f"Unknown risk limit {limit.name!r} " + f"(expected one of {', '.join(sorted(RISK_LIMITS))})", + limit.line, + limit.column, + ) + if limit.name in seen: + raise self._fail( + f"Risk limit {limit.name!r} is set twice", + limit.line, + limit.column, + ) + seen.add(limit.name) + + unit, low, high = spec + if limit.name in INTEGER_RISK_LIMITS and not _is_plain_int(limit.value): + raise self._fail( + f"Risk limit {limit.name!r} is measured in {unit} and must " + f"be a whole number, got {limit.value}", + limit.line, + limit.column, + ) + if limit.value < low or (high is not None and limit.value > high): + bound = f"[{low}, {high}]" if high is not None else f">= {low}" + raise self._fail( + f"Risk limit {limit.name!r} is measured in {unit} and must " + f"be {bound}, got {limit.value}", + limit.line, + limit.column, + ) + + # -- routes ------------------------------------------------------------ + + def _check_routes(self) -> None: + for route in self.strategy.routes: + self._require_tier("route", route.line, route.column) + self._declare( + Symbol( + name=route.name, + type=BOOL, + kind=KIND_ROUTE, + line=route.line, + column=route.column, + ) + ) + self._expect_condition(route.when, context=f"route {route.name!r}") + self._check_escalate(route.otherwise) + + # -- schedules --------------------------------------------------------- + + def _check_schedules(self) -> None: + for schedule in self.strategy.schedules: + for rule in schedule.rules: + self._check_rule(rule) + + def _check_rule(self, rule: RuleAst) -> None: + self._expect_condition(rule.when, context="rule condition") + for statement in (*rule.then, *rule.otherwise): + self._check_statement(statement) + + def _check_statement(self, statement: Stmt) -> None: + if isinstance(statement, ActionAst): + if statement.action not in INTENT_ACTIONS: + raise self._fail( + f"Unknown intent action {statement.action!r}", + statement.line, + statement.column, + ) + self.effects.add("intent.emit") + return + + if isinstance(statement, EscalateStmt): + self._check_escalate(statement) + return + + if isinstance(statement, IfStmt): + self._expect_condition(statement.when, context="nested condition") + for nested in (*statement.then, *statement.otherwise): + self._check_statement(nested) + return + + raise self._fail( + f"Unsupported statement {type(statement).__name__}", + statement.line, + statement.column, + ) + + def _check_escalate(self, statement: EscalateStmt) -> None: + self._require_tier("escalate", statement.line, statement.column) + self.effects.add("llmre.escalate") + + if not statement.is_name: + # A quoted target is opaque: the host resolves it, so there is + # nothing to verify here beyond it being non-empty. + if not statement.target: + raise self._fail( + "Escalation target must not be empty", + statement.line, + statement.column, + ) + return + + symbol = self.scope.get(statement.target) + if symbol is None or symbol.kind != KIND_AGENT: + raise self._fail( + f"Escalation target {statement.target!r} is not a declared agent " + f"(declare `agent {statement.target}`, or quote the name to let " + "the host resolve it)", + statement.line, + statement.column, + ) + + def _expect_condition(self, expr: Expr, *, context: str) -> None: + """A condition must be a boolean, per-bar or otherwise.""" + condition_type, lookback = self._visit(expr) + if condition_type.scalar != BOOL: + raise self._fail( + f"A {context} must be a boolean expression, got {condition_type}", + expr.line, + expr.column, + ) + self.warmup = max(self.warmup, lookback) + + # -- expressions ------------------------------------------------------- + + def _visit(self, expr: Expr) -> Tuple[Type, int]: + """Type `expr` and report the bars of history it consumes.""" + result = self._visit_uncached(expr) + self.expr_types[id(expr)] = result[0] + return result + + def _visit_uncached(self, expr: Expr) -> Tuple[Type, int]: + if isinstance(expr, NumberLit): + return (INT if _is_plain_int(expr.value) else FLOAT), 0 + if isinstance(expr, StringLit): + return STRING, 0 + if isinstance(expr, BoolLit): + return BOOL, 0 + if isinstance(expr, DurationLit): + return DURATION, 0 + if isinstance(expr, Name): + return self._visit_name(expr) + if isinstance(expr, Index): + return self._visit_index(expr) + if isinstance(expr, Member): + return self._visit_member(expr) + if isinstance(expr, Call): + return self._visit_call(expr) + if isinstance(expr, Unary): + return self._visit_unary(expr) + if isinstance(expr, Binary): + return self._visit_binary(expr) + raise self._fail( + f"Unsupported expression {type(expr).__name__}", expr.line, expr.column + ) + + def _visit_name(self, expr: Name) -> Tuple[Type, int]: + symbol = self.scope.get(expr.name) + if symbol is not None: + if symbol.kind == KIND_SIGNATURE: + raise self._fail( + f"Signature {expr.name!r} cannot be read as a value — call " + f"it with infer({expr.name}, ...)", + expr.line, + expr.column, + ) + if symbol.kind in (KIND_AGENT, KIND_ROUTE): + raise self._fail( + f"{symbol.kind.capitalize()} {expr.name!r} cannot be read " + "as a value", + expr.line, + expr.column, + ) + # Only a `let` carries intrinsic warm-up — the cost of computing it. + # An input or feed signal arrives raw, so reading it costs nothing. + # Reading `symbol.lookback` for those would compound: after + # `close[3]` widened the symbol's recorded requirement to 3, a later + # `close[1]` would type as needing 4 bars instead of 1. + intrinsic = symbol.lookback if symbol.kind == KIND_LET else 0 + return symbol.type, intrinsic + + if self.scope.strict: + raise self._fail( + f"Unknown name {expr.name!r} (this strategy declares its inputs, " + "so undeclared names are errors — add " + f"`input {expr.name}: series` if the host supplies it)", + expr.line, + expr.column, + ) + + # v0.1.0 territory: an undeclared name is a host-supplied signal. + self.scope.declare_feed( + expr.name, SERIES_FLOAT, line=expr.line, column=expr.column + ) + self.resolutions[id(expr)] = ResolvedFeed(expr.name) + return SERIES_FLOAT, 0 + + def _visit_index(self, expr: Index) -> Tuple[Type, int]: + # Validate the offset before typing it. An offset like `t + 1` would + # otherwise register `t` as a feed signal on its way to being rejected, + # leaving a phantom signal in the manifest of a failed compile. + offset = resolve_offset(expr, self.scope) + self.expr_types[id(expr.offset)] = INT + + target_type, target_lookback = self._visit(expr.target) + if not target_type.is_series: + raise self._fail( + f"Cannot index {target_type} — only a series has history " + "(indexing reads N bars back)", + expr.line, + expr.column, + ) + total = target_lookback + offset + if isinstance(expr.target, Name): + symbol = self.scope.get(expr.target.name) + # Record how much history the *host* must supply for this name, but + # only for data sources. Widening a `let` would feed back into its own + # intrinsic warm-up the next time it is read. + if symbol is not None and symbol.kind in (KIND_FEED, KIND_INPUT): + self.scope.widen_lookback(expr.target.name, total) + return target_type, total + + def _visit_member(self, expr: Member) -> Tuple[Type, int]: + target_type, lookback = self._visit(expr.target) + if target_type.name != "record" or target_type.element is None: + raise self._fail( + f"Cannot read field {expr.field_name!r} from {target_type} — " + "only an infer() result has named outputs", + expr.line, + expr.column, + ) + signature_name = target_type.element.name + symbol = self.scope.get(signature_name) + fields = dict(symbol.fields) if symbol is not None else {} + if expr.field_name not in fields: + available = ", ".join(sorted(fields)) or "none" + raise self._fail( + f"Signature {signature_name!r} declares no output " + f"{expr.field_name!r} (outputs: {available})", + expr.line, + expr.column, + ) + return fields[expr.field_name], lookback + + # -- calls ------------------------------------------------------------- + + def _visit_call(self, expr: Call) -> Tuple[Type, int]: + if expr.callee == "infer": + return self._visit_infer(expr) + + spec = lookup_indicator(expr.callee) + if self._is_feed_signal_form(expr, spec): + self.scope.declare_feed( + expr.callee, SERIES_FLOAT, line=expr.line, column=expr.column + ) + self.resolutions[id(expr)] = ResolvedFeed(expr.callee) + self.expr_types[id(expr.args[0])] = INT + return SERIES_FLOAT, 0 + + if spec is None: + raise self._fail( + f"Unknown function {expr.callee!r} (a call with a single " + "constant integer argument is read as a host-supplied signal, " + f"e.g. {expr.callee}(14); anything else must be a known indicator)", + expr.line, + expr.column, + ) + return self._visit_indicator(expr, spec) + + def _is_feed_signal_form(self, expr: Call, spec: Optional[IndicatorSpec]) -> bool: + """Is this the v0.1.0 `SIGNAL(period)` form the host supplies? + + One argument, a compile-time integer, and not one of the elementwise + maths helpers whose leading parameter is a plain `float` — `ABS(3)` is a + real call, while `RSI(14)` and `SMA_SPREAD(50)` are signal names. + """ + if len(expr.args) != 1: + return False + if fold_int(expr.args[0], self.scope) is None: + return False + if spec is not None and spec.params and spec.params[0] == str(FLOAT): + return False + return True + + def _param_type(self, spec: IndicatorSpec, position: int) -> Type: + """Resolve one parameter's declared spelling into a Type. + + The registry stores spellings rather than Type objects so it stays a leaf + module (see its docstring); this is where they become types. + """ + spelling = spec.params[position] + parsed = parse_type(spelling) + if parsed is None: # pragma: no cover - a malformed registry entry + raise self._fail( + f"Indicator {spec.name} declares unknown parameter type " + f"{spelling!r}", + 0, + 0, + ) + return parsed + + def _return_type(self, spec: IndicatorSpec) -> Type: + parsed = parse_type(spec.returns) + if parsed is None: # pragma: no cover - a malformed registry entry + raise self._fail( + f"Indicator {spec.name} declares unknown return type " + f"{spec.returns!r}", + 0, + 0, + ) + return parsed + + def _visit_indicator(self, expr: Call, spec: IndicatorSpec) -> Tuple[Type, int]: + if len(expr.args) != spec.arity: + raise self._fail( + f"{spec.name} takes {spec.arity} argument(s), got " + f"{len(expr.args)} — {spec.signature_text()}", + expr.line, + expr.column, + ) + + periods: list[int] = [] + lookback = 0 + lifted = False + + for index, argument in enumerate(expr.args): + position = index + 1 + parameter = self._param_type(spec, index) + if parameter == INT: + periods.append( + resolve_period( + argument, self.scope, indicator=spec.name, position=position + ) + ) + self.expr_types[id(argument)] = INT + continue + + argument_type, argument_lookback = self._visit(argument) + lookback = max(lookback, argument_lookback) + + if parameter.is_series: + if not argument_type.is_series: + raise self._fail( + f"Argument {position} of {spec.name} needs history and " + f"must be a {parameter}, got {argument_type} — " + f"{spec.signature_text()}", + argument.line, + argument.column, + ) + if not is_assignable(argument_type, parameter): + raise self._fail( + f"Argument {position} of {spec.name} expects " + f"{parameter}, got {argument_type}", + argument.line, + argument.column, + ) + continue + + # A plain scalar parameter: accept a scalar, or lift over a series. + if argument_type.is_series: + lifted = True + if not is_assignable(argument_type.scalar, parameter): + raise self._fail( + f"Argument {position} of {spec.name} expects {parameter}, " + f"got {argument_type} — {spec.signature_text()}", + argument.line, + argument.column, + ) + + warmup = spec.lookback(tuple(periods)) + lookback + returns = self._return_type(spec) + if lifted and not returns.is_series: + returns = series(returns) + + self.resolutions[id(expr)] = ResolvedIndicator( + name=spec.name, + spec=spec, + periods=tuple(periods), + lookback=warmup, + lifted=lifted, + ) + return returns, warmup + + def _visit_infer(self, expr: Call) -> Tuple[Type, int]: + self._require_tier("infer", expr.line, expr.column) + if not expr.args: + raise self._fail( + "infer() needs a signature as its first argument, e.g. " + "infer(Sentiment, headline)", + expr.line, + expr.column, + ) + target = expr.args[0] + if not isinstance(target, Name): + raise self._fail( + "The first argument to infer() must be a declared signature name", + target.line, + target.column, + ) + signature = self.signatures.get(target.name) + if signature is None: + raise self._fail( + f"{target.name!r} is not a declared signature", + target.line, + target.column, + ) + self.expr_types[id(target)] = record(signature.name) + + supplied = expr.args[1:] + if len(supplied) != len(signature.inputs): + raise self._fail( + f"Signature {signature.name!r} declares " + f"{len(signature.inputs)} input(s), got {len(supplied)}", + expr.line, + expr.column, + ) + + lookback = 0 + for field, argument in zip(signature.inputs, supplied): + argument_type, argument_lookback = self._visit(argument) + lookback = max(lookback, argument_lookback) + expected = self._resolve_annotation( + field.declared_type, field.line, field.column + ) + if not is_assignable(argument_type.scalar, expected.scalar): + raise self._fail( + f"Input {field.name!r} of signature {signature.name!r} " + f"expects {expected}, got {argument_type}", + argument.line, + argument.column, + ) + + self.effects.add("llm.call") + self.resolutions[id(expr)] = ResolvedInfer(signature.name) + return record(signature.name), lookback + + # -- operators --------------------------------------------------------- + + def _visit_unary(self, expr: Unary) -> Tuple[Type, int]: + operand_type, lookback = self._visit(expr.operand) + if expr.op == "-": + if not operand_type.scalar.is_numeric: + raise self._fail(f"Cannot negate {operand_type}", expr.line, expr.column) + return operand_type, lookback + if expr.op == "not": + if operand_type.scalar != BOOL: + raise self._fail( + f"`not` needs a boolean, got {operand_type}", + expr.line, + expr.column, + ) + return operand_type, lookback + raise self._fail(f"Unknown unary operator {expr.op!r}", expr.line, expr.column) + + def _visit_binary(self, expr: Binary) -> Tuple[Type, int]: + left_type, left_lookback = self._visit(expr.left) + right_type, right_lookback = self._visit(expr.right) + lookback = max(left_lookback, right_lookback) + + if expr.op in _ARITHMETIC_OPS: + result = unify_numeric(left_type, right_type) + if result is None: + raise self._fail( + f"Cannot apply {expr.op!r} to {left_type} and {right_type}", + expr.line, + expr.column, + ) + if expr.op == "/": + # Division always produces a float. An int-preserving `/` would + # make `fast / 2` silently truncate, and truncation that depends + # on operand types is exactly the surprise a typed language owes + # the author protection from. + result = lift(FLOAT, as_series=result.is_series) + return result, lookback + + if expr.op in _COMPARISON_OPS: + comparison = unify_comparison(left_type, right_type) + if comparison is None: + raise self._fail( + f"Cannot compare {left_type} with {right_type}", + expr.line, + expr.column, + ) + return comparison, lookback + + if expr.op in _LOGICAL_OPS: + for operand_type, operand in ( + (left_type, expr.left), + (right_type, expr.right), + ): + if operand_type.scalar != BOOL: + raise self._fail( + f"{expr.op!r} needs boolean operands, got {operand_type}", + operand.line, + operand.column, + ) + as_series = left_type.is_series or right_type.is_series + return lift(BOOL, as_series=as_series), lookback + + raise self._fail(f"Unknown operator {expr.op!r}", expr.line, expr.column) + + +def check(strategy: StrategyAst) -> TypedProgram: + """Type-check a parsed strategy. Raises ``NanoTypeError`` on the first fault.""" + return _Checker(strategy).run() diff --git a/nano/types/env.py b/nano/types/env.py new file mode 100644 index 0000000..cd55cf3 --- /dev/null +++ b/nano/types/env.py @@ -0,0 +1,143 @@ +"""Symbol table for one compilation. + +A symbol is a name the checker resolved, plus everything later stages need to +know about it: its type, where it came from, whether it is a compile-time +constant, and how many bars of history it consumes. + +`kind` is not decoration. It decides what the name is *allowed* to do: + +| kind | Introduced by | Notes | +|---|---|---| +| `param` | `param fast: int = 20` | constant — may appear as an indicator period | +| `input` | `input price: series` | supplied by the host feed | +| `let` | `let ema20 = EMA(price, 20)` | derived; carries a warm-up length | +| `feed` | an undeclared name, e.g. `RSI` in `RSI < 30` | the v0.1.0 signal form | +| `builtin` | the language itself, e.g. `confidence` | read-only | +| `signature` | `signature Sentiment { … }` | callable only through `infer` | +| `agent` | `agent Research` | an escalation target | +| `route` | `route Decision { … }` | a confidence-routing entry point | + +**Declaring inputs turns on strict name resolution.** With no `input` +declarations a strategy is in v0.1.0 territory, where any unknown name is a +host-supplied feed signal — that is what makes `if RSI(14) < 30` work with no +preamble, and it must keep working. The cost is that a typo becomes a signal +that never arrives. So the moment a strategy declares even one `input`, it has +told the compiler what its data is, and unknown names become errors. Strictness +is opt-in by being explicit, which is the trade a typed language should offer. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Dict, Mapping, Optional, Tuple + +from ..compiler.ast import Literal +from .kinds import Type + +KIND_PARAM = "param" +KIND_INPUT = "input" +KIND_LET = "let" +KIND_FEED = "feed" +KIND_BUILTIN = "builtin" +KIND_SIGNATURE = "signature" +KIND_AGENT = "agent" +KIND_ROUTE = "route" + +# Kinds whose value is fixed before any data arrives, so they may be used where +# the compiler needs a number at compile time (indicator periods, risk limits). +CONSTANT_KINDS = frozenset({KIND_PARAM}) + + +@dataclass(frozen=True) +class Symbol: + name: str + type: Type + kind: str + line: int = 0 + column: int = 0 + # Set for `param` declarations; lets an indicator period resolve statically. + const_value: Optional[Literal] = None + # Bars of history this name consumes before it produces a value. + lookback: int = 0 + # For `signature` symbols: declared output fields, so `.field` can be typed. + fields: Tuple[Tuple[str, Type], ...] = () + + @property + def is_constant(self) -> bool: + return self.kind in CONSTANT_KINDS and self.const_value is not None + + def describe(self) -> str: + """`param fast: int = 20` — the hover text for this symbol.""" + head = f"{self.kind} {self.name}: {self.type}" + if self.const_value is not None: + return f"{head} = {self.const_value!r}" + return head + + +class Scope: + """Flat name -> Symbol table. + + Flat because Nano has no nested lexical scopes: a strategy's declarations + are all visible to each other and to every schedule block, and nothing + shadows anything. Redeclaration is an error rather than a shadow, so this + stays one namespace and lookups need no chain walk. + """ + + def __init__(self, *, strict: bool = False) -> None: + self._symbols: Dict[str, Symbol] = {} + self.strict = strict + + # -- reads ------------------------------------------------------------- + + def get(self, name: str) -> Optional[Symbol]: + return self._symbols.get(name) + + def __contains__(self, name: object) -> bool: + return name in self._symbols + + def names(self) -> Tuple[str, ...]: + return tuple(self._symbols) + + def of_kind(self, kind: str) -> Tuple[Symbol, ...]: + """Symbols of one kind, in declaration order.""" + return tuple(s for s in self._symbols.values() if s.kind == kind) + + def snapshot(self) -> Mapping[str, Symbol]: + return dict(self._symbols) + + # -- writes ------------------------------------------------------------ + + def declare(self, symbol: Symbol) -> Optional[Symbol]: + """Bind `symbol`. Returns the existing symbol on collision, else None. + + The caller raises, because only it knows which position to blame — the + scope has no opinion about diagnostics. + """ + existing = self._symbols.get(symbol.name) + if existing is not None: + return existing + self._symbols[symbol.name] = symbol + return None + + def declare_feed(self, name: str, type_: Type, *, line: int, column: int) -> Symbol: + """Record an undeclared name as a host-supplied feed signal. + + Idempotent: the same signal referenced from three conditions is one + symbol, attributed to its first appearance. + """ + existing = self._symbols.get(name) + if existing is not None: + return existing + symbol = Symbol(name=name, type=type_, kind=KIND_FEED, line=line, column=column) + self._symbols[name] = symbol + return symbol + + def widen_lookback(self, name: str, lookback: int) -> None: + """Raise a symbol's recorded history requirement to at least `lookback`. + + A feed signal read as `RSI[3]` needs three more bars than one read as + `RSI`; warm-up is the maximum over every use, never the last one seen. + """ + symbol = self._symbols.get(name) + if symbol is not None and lookback > symbol.lookback: + self._symbols[name] = replace(symbol, lookback=lookback) diff --git a/nano/types/kinds.py b/nano/types/kinds.py new file mode 100644 index 0000000..b320fbd --- /dev/null +++ b/nano/types/kinds.py @@ -0,0 +1,164 @@ +"""Nano's type vocabulary. + +Types are immutable value objects with a canonical textual form: `int`, +`float`, `series`. That rendering is the single spelling used in +diagnostics, IR (`inputs[].type`), editor hovers, and `.nano` source +annotations, so a type never reads differently depending on who printed it. + +Two rules carry most of the weight: + +* **Integer literals widen, nothing else does.** `RSI < 30` must typecheck with + `RSI: float` and `30: int` — a language that rejected it would be unusable. + Nothing else coerces: `float` never narrows to `int`, `bool` is not a number, + and `string` converts to nothing. +* **Series lift pointwise.** Comparing `series` to `float` yields + `series` — the comparison happens at every bar, not once. A condition + position accepts `bool` or `series` and samples the latter at the + current bar, which is what makes `if ema20 > price { ... }` mean what a + strategy author expects. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Tuple + + +@dataclass(frozen=True) +class Type: + """A Nano type. `element` is set only for `series`.""" + + name: str + element: Optional["Type"] = None + + def __str__(self) -> str: + if self.element is not None: + return f"{self.name}<{self.element}>" + return self.name + + # -- shape predicates -------------------------------------------------- + + @property + def is_series(self) -> bool: + return self.name == "series" + + @property + def is_numeric(self) -> bool: + return self.name in _NUMERIC_NAMES + + @property + def scalar(self) -> "Type": + """This type with any series wrapper removed (`series` -> `float`).""" + return self.element if self.element is not None else self + + +INT = Type("int") +FLOAT = Type("float") +BOOL = Type("bool") +STRING = Type("string") +DURATION = Type("duration") +# A float in [0, 1] carrying provenance. Distinct from `float` so a raw +# measurement can never be passed where a calibrated confidence is required. +CONFIDENCE = Type("confidence") +VOID = Type("void") + +_NUMERIC_NAMES = frozenset({"int", "float", "confidence"}) + +SCALAR_TYPES = {t.name: t for t in (INT, FLOAT, BOOL, STRING, DURATION, CONFIDENCE)} + + +def series(element: Type) -> Type: + """`series` — a time-indexed, look-ahead-safe sequence.""" + if element.is_series: + raise ValueError("series> is not a Nano type") + return Type("series", element) + + +SERIES_FLOAT = series(FLOAT) +SERIES_BOOL = series(BOOL) + + +def record(signature_name: str) -> Type: + """`record` — the result of one `infer` call against a signature. + + The signature name rides in `element` so the type prints readably and the + checker can look the declaration back up to type a `.field` access. A record + is deliberately not a struct literal: the only way to make one is to call a + model through a declared signature, so an untyped bag of model output has no + way into the language. + """ + return Type("record", Type(signature_name)) + + +def lift(element: Type, *, as_series: bool) -> Type: + """Wrap `element` in a series when the surrounding expression is one.""" + return series(element) if as_series else element + + +def is_assignable(source: Type, target: Type) -> bool: + """Can a value of `source` be used where `target` is required? + + Identity, integer widening, and confidence-as-float. Series are assignable + only to series whose elements are assignable — a bare `float` never + satisfies a `series` parameter, because the callee needs history. + """ + if source == target: + return True + if source.is_series or target.is_series: + if not (source.is_series and target.is_series): + return False + return is_assignable(source.element, target.element) + if source == INT and target in (FLOAT, CONFIDENCE): + return True + if source == CONFIDENCE and target == FLOAT: + return True + return False + + +def unify_numeric(left: Type, right: Type) -> Optional[Type]: + """Result type of an arithmetic operation, or None if the operands don't fit. + + `int op int` stays `int` so integer params keep exact arithmetic; any float + or confidence operand widens the result to `float`. Series-ness propagates: + if either side is a series, so is the result. + """ + as_series = left.is_series or right.is_series + lhs, rhs = left.scalar, right.scalar + if not (lhs.is_numeric and rhs.is_numeric): + return None + if lhs == INT and rhs == INT: + return lift(INT, as_series=as_series) + return lift(FLOAT, as_series=as_series) + + +def unify_comparison(left: Type, right: Type) -> Optional[Type]: + """Result type of a comparison, or None if the operands aren't comparable. + + Numerics compare with numerics; otherwise the scalar types must match + exactly (`string == string`, `bool != bool`). Series-ness propagates, so a + comparison against history is itself history. + """ + as_series = left.is_series or right.is_series + lhs, rhs = left.scalar, right.scalar + comparable = (lhs.is_numeric and rhs.is_numeric) or lhs == rhs + return lift(BOOL, as_series=as_series) if comparable else None + + +def parse_type(text: str) -> Optional[Type]: + """Parse a canonical type spelling. Returns None for anything unknown.""" + text = text.strip() + if text.startswith("series<") and text.endswith(">"): + element = parse_type(text[len("series<") : -1]) + if element is None or element.is_series: + return None + return series(element) + return SCALAR_TYPES.get(text) + + +def type_names() -> Tuple[str, ...]: + """Every spelling a type annotation may use, for diagnostics and completions.""" + return tuple(sorted(SCALAR_TYPES)) + ( + "series", + "series", + "series", + ) diff --git a/nano/types/lookahead.py b/nano/types/lookahead.py new file mode 100644 index 0000000..6016f04 --- /dev/null +++ b/nano/types/lookahead.py @@ -0,0 +1,138 @@ +"""Look-ahead protection. + +A backtest that reads one bar into the future looks brilliant and is worthless. +The bug is invisible in results — equity curves get *better*, not noisier — so +Nano refuses to represent it rather than trying to detect it after the fact. + +Three properties combine to make future reads unreachable: + +1. **Offsets only count backwards.** `price[k]` means "k bars ago". There is no + forward-indexing syntax, so `close[t+1]` cannot mean next bar's close — it + can only mean `t+1` bars *ago*. +2. **Offsets must fold to a non-negative integer at compile time.** `price[-1]` + folds to `-1` and is rejected. `close[t+1]`, where `t` is not a compile-time + constant, does not fold at all and is also rejected: an offset the compiler + cannot bound is an offset that could point anywhere. +3. **Warm-up is never filled in.** A bar with insufficient history yields no + value, and the VM will not emit an intent from it (see + ``nano/indicators/compute.py``). Fabricating a warm-up value is look-ahead + wearing a different hat — both invent data the strategy could not have had. + +Rejecting non-constant offsets is stricter than it has to be. A dynamic offset +provably in `[0, n]` would be sound, but proving it needs range analysis, and +the failure mode of getting that wrong is a silently optimistic backtest. Until +there is a real use case, the strict rule costs a rewrite and buys a guarantee. +""" + +from __future__ import annotations + +from typing import Optional + +from ..compiler.ast import Binary, Expr, Index, Name, NumberLit, Unary +from ..compiler.errors import LookaheadError, NanoTypeError +from .env import Scope + +# Arithmetic the folder understands. Enough to write `slow - 1` or `period * 2` +# as an offset or period; deliberately not a general evaluator. +_INTEGER_OPS = frozenset({"+", "-", "*", "%"}) + + +def _is_plain_int(value: object) -> bool: + """True for a real integer. `bool` is an int in Python and is not one here.""" + return isinstance(value, int) and not isinstance(value, bool) + + +def fold_int(expr: Expr, scope: Scope) -> Optional[int]: + """Evaluate `expr` to an integer at compile time, or None if it cannot be. + + Folds integer literals, `param` references with integer values, unary minus, + and integer arithmetic over those. Anything touching runtime data — a feed + signal, an input, a let-binding, a float — does not fold, by design. + """ + if isinstance(expr, NumberLit): + return expr.value if _is_plain_int(expr.value) else None + + if isinstance(expr, Name): + symbol = scope.get(expr.name) + if ( + symbol is not None + and symbol.is_constant + and _is_plain_int(symbol.const_value) + ): + return int(symbol.const_value) + return None + + if isinstance(expr, Unary): + inner = fold_int(expr.operand, scope) + if inner is None: + return None + return -inner if expr.op == "-" else None + + if isinstance(expr, Binary) and expr.op in _INTEGER_OPS: + left = fold_int(expr.left, scope) + right = fold_int(expr.right, scope) + if left is None or right is None: + return None + if expr.op == "+": + return left + right + if expr.op == "-": + return left - right + if expr.op == "*": + return left * right + return left % right if right != 0 else None + + return None + + +def resolve_offset(node: Index, scope: Scope) -> int: + """Validate a series offset and return it. + + Raises ``LookaheadError`` when the offset is negative or not resolvable at + compile time — the two ways a program could end up reading the future. + """ + offset = fold_int(node.offset, scope) + + if offset is None: + raise LookaheadError( + "Series offset must be a compile-time non-negative integer " + "(offsets count backwards, so an offset the compiler cannot bound " + "could reach into the future)", + node.offset.line, + node.offset.column, + ) + if offset < 0: + raise LookaheadError( + f"Series offset {offset} reads into the future " + "(offsets count backwards from the current bar; the minimum is 0)", + node.offset.line, + node.offset.column, + ) + return offset + + +def resolve_period(expr: Expr, scope: Scope, *, indicator: str, position: int) -> int: + """Validate an indicator period and return it. + + Periods must be positive compile-time constants: warm-up length has to be + known before any data arrives, and a zero or negative window has no + meaning. Raises ``NanoTypeError`` — a bad period is a typing mistake, not an + attempt to read the future. + """ + period = fold_int(expr, scope) + + if period is None: + raise NanoTypeError( + f"Argument {position} of {indicator} is a period and must be a " + "compile-time integer constant (a literal or a `param`), so warm-up " + "length is known before any data arrives", + expr.line, + expr.column, + ) + if period < 1: + raise NanoTypeError( + f"Argument {position} of {indicator} is a period and must be at " + f"least 1, got {period}", + expr.line, + expr.column, + ) + return period diff --git a/pyproject.toml b/pyproject.toml index 8feb484..d905bb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aether-nano" -version = "0.1.0" +version = "1.0.0" description = "A compiled execution architecture for autonomous engineering systems — compile AI reasoning into deterministic, auditable execution." readme = "README.md" license = { text = "MIT" } @@ -9,10 +9,11 @@ authors = [{ name = "Aether AI LLC" }] keywords = [ "nano", "aether", "compiler", "deterministic-execution", "autonomous-ai", "ai-agents", "quantitative-trading", "algorithmic-trading", "llm", - "execution-engine", "provenance", "dsl", + "execution-engine", "provenance", "dsl", "static-typing", "cli", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", + "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -22,8 +23,14 @@ classifiers = [ ] # Zero mandatory dependencies -- stdlib only, matching the reference runtime. +# The CLI is argparse, csv, and json; the type checker, indicators, and VM add +# nothing. A strategy compiler that pulls in a dependency tree is a compiler whose +# output depends on that tree resolving identically twice. dependencies = [] +[project.scripts] +nano = "nano.cli.main:main" + [project.urls] Homepage = "https://aethersystems.net" "Aether Code (web IDE)" = "https://app.aethersystems.net/code" diff --git a/tests/test_aethercode.py b/tests/test_aethercode.py index 0d4244c..701a728 100644 --- a/tests/test_aethercode.py +++ b/tests/test_aethercode.py @@ -65,11 +65,19 @@ def test_semantic_tokens_for_invalid_but_lexable_source(): def test_semantic_tokens_survive_lexer_failure(): - # '=' alone is unlexable; the tokens before it are still classified. - tokens = semantic_tokens("strategy S = 5") + # '$' is unlexable; the tokens before it are still classified. + tokens = semantic_tokens("strategy S $ 5") assert [t.kind for t in tokens] == ["keyword", "identifier"] +def test_assignment_is_lexable_since_params_and_lets_use_it(): + # '=' was unlexable in v0.1.0. v1.0 binds params and lets with it, so it is a + # real operator token, and `if x = 1` becomes a parser diagnostic rather than + # a lexer one -- reported at the same position either way. + tokens = semantic_tokens("param fast = 20") + assert [t.kind for t in tokens] == ["keyword", "identifier", "operator", "number"] + + # -- diagnostics ------------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..e96242e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,383 @@ +"""The `nano` CLI and the data adapters behind `replay`. + +Commands are driven through ``main()`` with argv lists and a captured console +rather than a subprocess: it is faster, and it asserts the exit codes CI actually +branches on. + +Exit codes are treated as interface. `nano check` returning 1 on a type error is +what makes it usable in a pre-commit hook, and a command that printed a diagnostic +while exiting 0 would be worse than one that crashed. +""" + +import io +import json + +import pytest + +from nano.cli.commands import EXIT_DIAGNOSTICS, EXIT_IO, EXIT_OK, Console +from nano.cli.main import build_parser, main +from nano.cli.render import render +from nano.compiler import compile_module +from nano.data import FeedError, load_frame, parse_date, parse_timestamp + +VALID = ( + "strategy Momentum {\n" + " input close: series\n" + " let fast = SMA(close, 2)\n" + " every 1m {\n" + " if close > fast {\n" + " buy(BTCUSD, 0.85)\n" + " } else {\n" + " observe()\n" + " }\n" + " }\n" + "}\n" +) + +LEGACY = ( + "strategy Legacy {\n" + " every 15m {\n" + " if RSI(14) < 30 {\n" + " buy(BTCUSD, 0.85)\n" + " }\n" + " }\n" + "}\n" +) + +BARS_CSV = ( + "timestamp,close\n" + "2026-01-15T00:00:00Z,100\n" + "2026-01-15T00:01:00Z,105\n" + "2026-01-15T00:02:00Z,101\n" + "2026-01-16T00:00:00Z,150\n" +) + + +def _run(argv, capsys): + """Run the CLI and return (code, stdout, stderr).""" + code = main(argv) + captured = capsys.readouterr() + return code, captured.out, captured.err + + +@pytest.fixture +def strategy(tmp_path): + path = tmp_path / "momentum.nano" + path.write_text(VALID, encoding="utf-8") + return path + + +@pytest.fixture +def legacy(tmp_path): + path = tmp_path / "legacy.nano" + path.write_text(LEGACY, encoding="utf-8") + return path + + +@pytest.fixture +def bars(tmp_path): + path = tmp_path / "bars.csv" + path.write_text(BARS_CSV, encoding="utf-8") + return path + + +# -- timestamps and dates ----------------------------------------------------- + + +def test_epoch_and_iso_timestamps_agree(): + # A naive ISO timestamp is read as UTC. Reading it as local time would make the + # same file replay differently on two machines. + assert parse_timestamp(1767225600) == 1767225600 + assert parse_timestamp("2026-01-01T00:00:00Z") == parse_timestamp( + "2026-01-01T00:00:00" + ) + + +def test_bad_timestamp_and_bad_date_are_rejected(): + with pytest.raises(FeedError, match="neither epoch seconds nor ISO-8601"): + parse_timestamp("not-a-time") + with pytest.raises(FeedError, match="ISO-8601"): + parse_date("15/01/2026") + + +# -- loading ------------------------------------------------------------------ + + +def test_csv_loads_and_date_filters_on_utc(bars): + loaded = load_frame(bars, on_date=parse_date("2026-01-15")) + assert (loaded.rows_read, loaded.rows_kept, loaded.rows_filtered) == (4, 3, 1) + assert loaded.signal_names == ("close",) + + +def test_blank_cells_become_absent_not_zero(tmp_path): + path = tmp_path / "gappy.csv" + path.write_text("timestamp,close\n0,100\n60,\n120,102\n", encoding="utf-8") + assert load_frame(path).frame.signals["close"] == (100.0, None, 102.0) + + +def test_rows_are_sorted_because_indicators_are_order_sensitive(tmp_path): + path = tmp_path / "shuffled.csv" + path.write_text("timestamp,close\n120,3\n0,1\n60,2\n", encoding="utf-8") + loaded = load_frame(path) + assert loaded.frame.timestamps == (0, 60, 120) + assert loaded.frame.signals["close"] == (1.0, 2.0, 3.0) + + +def test_duplicate_timestamps_are_rejected(tmp_path): + path = tmp_path / "dupes.csv" + path.write_text("timestamp,close\n0,1\n0,2\n", encoding="utf-8") + with pytest.raises(FeedError, match="cannot occur twice"): + load_frame(path) + + +def test_both_json_shapes_load_to_the_same_frame(tmp_path): + columnar = tmp_path / "columnar.json" + columnar.write_text( + json.dumps({"timestamps": [0, 60], "signals": {"close": [1.0, 2.0]}}), + encoding="utf-8", + ) + rows = tmp_path / "rows.json" + rows.write_text( + json.dumps([{"timestamp": 0, "close": 1.0}, {"timestamp": 60, "close": 2.0}]), + encoding="utf-8", + ) + assert load_frame(columnar).frame == load_frame(rows).frame + + +def test_unsupported_format_is_reported(tmp_path): + path = tmp_path / "bars.parquet" + path.write_text("", encoding="utf-8") + with pytest.raises(FeedError, match="Unsupported data format"): + load_frame(path) + + +# -- nano check --------------------------------------------------------------- + + +def test_check_is_silent_on_success(strategy, capsys): + assert _run(["check", str(strategy)], capsys) == (EXIT_OK, "", "") + + +def test_check_verbose_reports_tier_warmup_and_ir_version(strategy, capsys): + code, out, _ = _run(["check", str(strategy), "-v"], capsys) + assert code == EXIT_OK + assert "tier nano" in out + assert "warmup 1 bar(s)" in out + assert "ir 1.0.0" in out + + +def test_check_reports_position_in_the_form_editors_parse(tmp_path, capsys): + path = tmp_path / "broken.nano" + path.write_text( + "strategy S {\n" + " input close: series\n" + " every 1m {\n" + " if close[-1] > close {\n" + " buy(BTC)\n" + " }\n" + " }\n" + "}\n", + encoding="utf-8", + ) + code, _, err = _run(["check", str(path)], capsys) + assert code == EXIT_DIAGNOSTICS + # Column 18 is the `-` of `-1`: the offset expression, not the `[` or the name. + assert f"{path}:4:18: error:" in err + assert "reads into the future" in err + + +def test_check_missing_file_is_an_io_error(tmp_path, capsys): + code, _, err = _run(["check", str(tmp_path / "absent.nano")], capsys) + assert code == EXIT_IO + assert "cannot read" in err + + +# -- nano compile ------------------------------------------------------------- + + +def test_compile_writes_ir_to_a_file(strategy, tmp_path, capsys): + out_path = tmp_path / "ir.json" + code, _, err = _run(["compile", str(strategy), "-o", str(out_path)], capsys) + assert code == EXIT_OK + document = json.loads(out_path.read_text(encoding="utf-8")) + assert document["nanoIrVersion"] == "1.0.0" + assert document["name"] == "Momentum" + # Progress goes to stderr so `-o -`-style piping of the artifact stays clean. + assert "nanoIrVersion 1.0.0" in err + + +def test_compile_emits_baseline_ir_for_a_baseline_strategy(legacy, capsys): + code, out, _ = _run(["compile", str(legacy)], capsys) + assert code == EXIT_OK + assert json.loads(out)["nanoIrVersion"] == "0.1.0" + + +def test_compile_can_be_forced_to_the_newer_version(legacy, capsys): + code, out, _ = _run(["compile", str(legacy), "--ir-version", "1.0.0"], capsys) + assert code == EXIT_OK + assert json.loads(out)["nanoIrVersion"] == "1.0.0" + + +def test_forcing_baseline_on_a_v1_strategy_explains_the_refusal(strategy, capsys): + code, _, err = _run(["compile", str(strategy), "--ir-version", "0.1.0"], capsys) + assert code == EXIT_DIAGNOSTICS + assert "cannot represent" in err + + +def test_compile_emit_types_lists_resolved_types(strategy, capsys): + code, out, _ = _run(["compile", str(strategy), "--emit", "types"], capsys) + assert code == EXIT_OK + assert "input close: series" in out + assert "let fast: series" in out + assert "warmup: 1 bar(s)" in out + + +def test_compile_emit_plan_renders_the_graph(strategy, capsys): + code, out, _ = _run(["compile", str(strategy), "--emit", "plan"], capsys) + assert code == EXIT_OK + assert "strategy Momentum" in out + assert "SMA(2)" in out + + +# -- nano replay -------------------------------------------------------------- + + +def test_replay_reports_intents_for_one_date(strategy, bars, capsys): + code, out, _ = _run( + ["replay", str(strategy), "--data", str(bars), "--date", "2026-01-15", "--verify"], + capsys, + ) + assert code == EXIT_OK + assert "bars 3 (1 row(s) filtered)" in out + assert "deterministic (verified over two runs)" in out + assert "BUY BTCUSD @0.85" in out + + +def test_replay_json_report_carries_hashes_and_the_audit_log(strategy, bars, capsys): + code, out, _ = _run( + ["replay", str(strategy), "--data", str(bars), "--report", "json"], capsys + ) + assert code == EXIT_OK + report = json.loads(out) + assert report["moduleHash"].startswith("sha256:") + assert report["sourceHash"].startswith("sha256:") + assert report["warmupDeclared"] == 1 + assert report["log"] # the run is auditable, not merely summarised + + +def test_replay_names_the_signal_the_data_lacks(strategy, tmp_path, capsys): + path = tmp_path / "wrong.csv" + path.write_text("timestamp,volume\n0,10\n60,20\n", encoding="utf-8") + code, _, err = _run(["replay", str(strategy), "--data", str(path)], capsys) + assert code == EXIT_IO + assert "does not supply close" in err + assert "it has: volume" in err + + +def test_replay_reports_an_empty_date_selection(strategy, bars, capsys): + code, _, err = _run( + ["replay", str(strategy), "--data", str(bars), "--date", "2020-01-01"], capsys + ) + assert code == EXIT_IO + assert "no rows to replay for 2020-01-01" in err + + +# -- nano visualize ----------------------------------------------------------- + + +@pytest.mark.parametrize("fmt", ["ascii", "mermaid", "dot", "json"]) +def test_visualize_renders_every_format(strategy, fmt, capsys): + code, out, _ = _run(["visualize", str(strategy), "-f", fmt], capsys) + assert code == EXIT_OK + assert out.strip() + + +def test_visualize_shows_a_shared_node_once(tmp_path, capsys): + # `close` feeds two indicators but is one data source; drawing it twice would + # imply two. + path = tmp_path / "shared.nano" + path.write_text( + "strategy S {\n" + " input close: series\n" + " let a = SMA(close, 2)\n" + " let b = SMA(close, 3)\n" + " every 1m {\n" + " if a > b {\n" + " buy(BTC)\n" + " }\n" + " }\n" + "}\n", + encoding="utf-8", + ) + code, out, _ = _run(["visualize", str(path)], capsys) + assert code == EXIT_OK + assert "(shared, see" in out + + +def test_graph_json_is_consumable_by_a_host_renderer(): + document = json.loads(render(compile_module(VALID), "json")) + assert document["moduleHash"].startswith("sha256:") + assert {"id", "op", "label", "isEntry"} <= set(document["nodes"][0]) + assert {"from", "to", "port"} <= set(document["edges"][0]) + assert document["entries"] + + +# -- nano indicators / version / help ---------------------------------------- + + +def test_indicators_lists_signatures(capsys): + code, out, _ = _run(["indicators"], capsys) + assert code == EXIT_OK + assert "EMA(series, int) -> series" in out + + +def test_indicators_describes_one_and_flags_constant_periods(capsys): + code, out, _ = _run(["indicators", "RSI"], capsys) + assert code == EXIT_OK + assert "Wilder" in out + assert "compile-time constants" in out + + +def test_unknown_indicator_exits_nonzero(capsys): + code, _, err = _run(["indicators", "NOPE"], capsys) + assert code == EXIT_DIAGNOSTICS + assert "unknown indicator" in err + + +def test_version_reports_both_ir_versions(capsys): + code, out, _ = _run(["version"], capsys) + assert code == EXIT_OK + assert "nano 1.0.0" in out + assert "0.1.0, 1.0.0" in out + + +def test_bare_invocation_prints_help_and_succeeds(capsys): + code, out, _ = _run([], capsys) + assert code == EXIT_OK + assert "compile" in out and "replay" in out and "visualize" in out + + +def test_parser_exposes_every_documented_command(): + parser = build_parser() + choices = next( + action.choices + for action in parser._subparsers._group_actions # noqa: SLF001 - argparse + if getattr(action, "choices", None) + ) + assert { + "check", + "compile", + "replay", + "visualize", + "indicators", + "version", + } <= set(choices) + + +def test_console_writes_where_it_is_told(): + out, err = io.StringIO(), io.StringIO() + console = Console(out=out, err=err) + console.say("to stdout") + console.warn("to stderr") + assert out.getvalue() == "to stdout\n" + assert err.getvalue() == "to stderr\n" diff --git a/tests/test_compiler.py b/tests/test_compiler.py index 9bc2b5f..2e791a5 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -239,8 +239,11 @@ def test_confidence_out_of_range(): assert "out of range [0, 1]" in err.message -def test_two_schedule_blocks(): - err = _compile_error( +def test_two_schedule_blocks_compile_as_v1_ir(): + # v0.1.0 allowed at most one schedule because its flat document had exactly + # one Schedule slot. v1.0's DAG holds several, so this now compiles -- and the + # version bump is the visible evidence that it needed the richer shape. + ir = compile_to_dict( "strategy S {\n" " every 5m {\n" " }\n" @@ -248,12 +251,18 @@ def test_two_schedule_blocks(): " }\n" "}\n" ) - assert (err.line, err.column) == (4, 5) - assert "one schedule block" in err.message + assert ir["nanoIrVersion"] == "1.0.0" + assert [n["attrs"]["interval"] for n in ir["nodes"] if n["op"] == "schedule"] == [ + "5m", + "1h", + ] -def test_two_rules_in_one_schedule(): - err = _compile_error( +def test_two_rules_in_one_schedule_compile_as_v1_ir(): + # Entry and exit in one schedule is the ordinary case for a real strategy. + # Baseline IR could not express two independent rules; v1.0 emits one `rule` + # node per rule, each with its own condition and body. + ir = compile_to_dict( "strategy S {\n" " every 5m {\n" " if RSI < 30 {\n" @@ -265,8 +274,17 @@ def test_two_rules_in_one_schedule(): " }\n" "}\n" ) - assert (err.line, err.column) == (6, 9) - assert "one rule" in err.message + assert ir["nanoIrVersion"] == "1.0.0" + assert len([n for n in ir["nodes"] if n["op"] == "rule"]) == 2 + assert len(ir["entries"]) == 2 + # One shared feed.signal node: RSI is read twice but is one data source. + assert len([n for n in ir["nodes"] if n["op"] == "feed.signal"]) == 1 + + +def test_single_rule_strategies_still_emit_baseline_ir(): + # The other half of the contract: reaching v1.0 happens only when the program + # actually needs it. Anything baseline can express stays byte-identical. + assert compile_to_dict(MOMENTUM_SRC)["nanoIrVersion"] == "0.1.0" def test_unterminated_block(): diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 5dc6f14..01cfcee 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -14,6 +14,7 @@ from nano.compiler import compile_source, compile_to_dict from nano.runtime.interpreter import MarketFrame, execute +from nano.runtime.vm import run_module EXAMPLES = Path(__file__).resolve().parent.parent / "nano" / "examples" @@ -45,3 +46,39 @@ def test_compiled_graph_replays_identically(nano_path: Path): first = execute(graph, frame).to_dict() second = execute(graph, frame).to_dict() assert first == second + + +@pytest.mark.parametrize("nano_path", NANO_SOURCES, ids=lambda p: p.stem) +def test_corpus_stays_on_baseline_ir(nano_path: Path): + """The corpus must keep compiling to v0.1.0 rather than drifting up a version. + + Byte-stability of compiled output is this repository's central claim, and the + compiler emits the lowest IR version that can express a program. If an example + ever starts emitting `1.0.0`, either it gained a v1.0 construct or the version + inference regressed — both worth noticing deliberately instead of discovering + in a host that pinned the older shape. + """ + assert compile_to_dict(nano_path.read_text())["nanoIrVersion"] == "0.1.0" + + +@pytest.mark.parametrize("nano_path", NANO_SOURCES, ids=lambda p: p.stem) +def test_baseline_and_v1_execution_paths_agree(nano_path: Path): + """The reference interpreter and the v1.0 VM must produce identical intents. + + This is the weld that lets v1.0 exist without invalidating a single v0.1.0 + artifact. ``interpreter.execute`` defines correct behavior for baseline graphs; + the VM is what actually runs under v1.0. Lifting each corpus entry through + ``to_module()`` and comparing proves the newer path reproduces the older one + rather than merely resembling it. + + The frame drives each signal low, high, then mid-range, so every comparison + operator in the corpus gets a bar where it fires and one where it does not — + an all-firing frame would pass even if one path ignored conditions entirely. + """ + graph = compile_source(nano_path.read_text()) + signals = {c.signal: (0.0, 1e9, 45.0) for c in graph.conditions} + frame = MarketFrame(timestamps=(0, 86400, 172800), signals=signals) + + reference = [i.to_dict() for i in execute(graph, frame).intents] + through_vm = [i.to_dict() for i in run_module(graph.to_module(), frame).intents] + assert reference == through_vm diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..e07c05d --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,358 @@ +"""Type system and look-ahead protection. + +Two things are pinned here. First, that the checker accepts the programs a +strategy author actually writes and rejects the ones that would silently +misbehave. Second — and this is the one that matters — that **look-ahead is +unrepresentable**, including the exact shape from the v1.0 brief: +`close[t+1] > close`. + +Every rejection asserts its line and column, because a diagnostic that points at +the wrong token is close to useless in an editor. +""" + +import pytest + +from nano.compiler import check_source +from nano.compiler.errors import LookaheadError, NanoTypeError +from nano.types import BOOL, FLOAT, INT, SERIES_FLOAT, parse_type, series +from nano.types.kinds import CONFIDENCE, is_assignable, unify_comparison, unify_numeric + +CLOSE = " input close: series\n" + + +def _strategy(body: str, decls: str = "") -> str: + return ( + "strategy S {\n" + + decls + + " every 5m {\n" + + f" if {body} {{\n" + + " buy(BTC)\n" + + " }\n" + + " }\n" + + "}\n" + ) + + +def _fails(body: str, decls: str = "") -> NanoTypeError: + with pytest.raises(NanoTypeError) as excinfo: + check_source(_strategy(body, decls)) + return excinfo.value + + +# -- the type vocabulary ------------------------------------------------------ + + +def test_type_rendering_round_trips(): + assert str(SERIES_FLOAT) == "series" + assert parse_type("series") == SERIES_FLOAT + assert parse_type("int") == INT + assert parse_type("series>") is None + assert parse_type("nonsense") is None + + +def test_integer_literals_widen_but_floats_never_narrow(): + # `RSI < 30` has to typecheck, so int widens to float. The reverse would let a + # price silently become a period. + assert is_assignable(INT, FLOAT) + assert not is_assignable(FLOAT, INT) + assert is_assignable(CONFIDENCE, FLOAT) + assert not is_assignable(BOOL, FLOAT) + + +def test_a_scalar_never_satisfies_a_series_parameter(): + # An indicator needs history; handing it one number cannot work, so this is a + # type error rather than a silent broadcast. + assert not is_assignable(FLOAT, SERIES_FLOAT) + assert is_assignable(series(INT), SERIES_FLOAT) + + +def test_comparisons_and_arithmetic_propagate_series_ness(): + assert unify_comparison(SERIES_FLOAT, FLOAT) == series(BOOL) + assert unify_comparison(FLOAT, FLOAT) == BOOL + assert unify_numeric(INT, INT) == INT + assert unify_numeric(INT, FLOAT) == FLOAT + assert unify_numeric(SERIES_FLOAT, INT) == SERIES_FLOAT + assert unify_comparison(FLOAT, parse_type("string")) is None + + +# -- look-ahead protection ---------------------------------------------------- + + +def test_future_index_from_the_brief_is_rejected(): + # The literal example from the v1.0 requirements. `t` is not a compile-time + # constant, so the offset cannot be bounded and the read is refused. + error = _fails("close[t+1] > close", CLOSE) + assert isinstance(error, LookaheadError) + assert (error.line, error.column) == (4, 19) + assert "compile-time non-negative integer" in error.message + + +def test_negative_offset_is_rejected_with_the_folded_value(): + error = _fails("close[-1] > close", CLOSE) + assert isinstance(error, LookaheadError) + assert (error.line, error.column) == (4, 18) + assert "reads into the future" in error.message + assert "-1" in error.message + + +def test_backwards_offsets_are_fine_and_accumulate_warmup(): + program = check_source(_strategy("close[3] > close[1]", CLOSE)) + assert program.warmup == 3 + + +def test_offset_may_reference_a_param_because_params_are_constants(): + program = check_source( + _strategy("close[lag] > close", " param lag: int = 4\n" + CLOSE) + ) + assert program.warmup == 4 + + +def test_indexing_a_scalar_is_an_error(): + error = _fails("lag[1] > 1", " param lag: int = 4\n") + assert "Cannot index int" in error.message + + +# -- indicator typing --------------------------------------------------------- + + +def test_periods_must_be_compile_time_constants(): + error = _fails( + "SMA(close, window) > 1", CLOSE + " input window: series\n" + ) + assert "compile-time integer constant" in error.message + + +def test_period_must_be_at_least_one(): + error = _fails("EMA(close, 0) > 1", CLOSE) + assert "must be at least 1" in error.message + + +def test_wrong_arity_reports_the_signature(): + error = _fails("EMA(close) > 1", CLOSE) + assert "EMA takes 2 argument(s), got 1" in error.message + assert "EMA(series, int) -> series" in error.message + + +def test_history_consuming_parameter_rejects_a_scalar(): + error = _fails("EMA(20, close) > 1", CLOSE) + assert "needs history" in error.message + + +def test_warmup_is_the_max_over_every_use(): + program = check_source( + "strategy S {\n" + " input close: series\n" + " let a = SMA(close, 5)\n" + " let b = EMA(close, 40)\n" + " every 5m {\n" + " if a > b {\n" + " buy(BTC)\n" + " }\n" + " }\n" + "}\n" + ) + assert program.warmup == 39 # EMA(40) warms at 39; SMA(5) at 4 + + +def test_scalar_maths_lifts_over_a_series(): + program = check_source(_strategy("ABS(close) > 1", CLOSE)) + assert program.warmup == 0 + + +# -- feed signals versus computed indicators ---------------------------------- + + +def test_undeclared_names_are_feed_signals_when_no_inputs_are_declared(): + # This is what keeps every v0.1.0 strategy compiling with no preamble. + program = check_source(_strategy("RSI(14) < 30")) + assert program.feed_signals == ("RSI",) + + +def test_declaring_an_input_turns_on_strict_name_resolution(): + error = _fails("clse > 1", CLOSE) + assert "Unknown name 'clse'" in error.message + + +def test_signal_call_form_still_works_in_strict_mode(): + # `SENTIMENT(1)` is unambiguously deliberate, so an explicit call stays legal + # even once the strategy declares its inputs -- only bare names go strict. + program = check_source(_strategy("SENTIMENT(1) > 0.5", CLOSE)) + assert "SENTIMENT" in program.feed_signals + + +# -- operators ---------------------------------------------------------------- + + +def test_conditions_must_be_boolean(): + error = _fails("close", CLOSE) + assert "must be a boolean expression" in error.message + + +def test_logical_operators_reject_non_booleans(): + error = _fails("close and true", CLOSE) + assert "needs boolean operands" in error.message + + +def test_incomparable_types_are_rejected(): + error = _fails('close > "text"', CLOSE) + assert "Cannot compare series with string" in error.message + + +def test_comparisons_do_not_chain(): + from nano.compiler.errors import NanoSyntaxError + + with pytest.raises(NanoSyntaxError) as excinfo: + check_source(_strategy("1 < 2 < 3")) + assert "do not chain" in excinfo.value.message + + +def test_division_always_produces_a_float(): + # An int-preserving `/` would make `fast / 2` truncate depending on operand + # types, which is exactly the surprise static typing should prevent. + program = check_source( + "strategy S {\n" + " param fast: int = 5\n" + " every 5m {\n" + " if fast / 2 > 2.4 {\n" + " buy(BTC)\n" + " }\n" + " }\n" + "}\n" + ) + assert program.tier == "nano" + + +# -- declarations ------------------------------------------------------------- + + +def test_params_cannot_be_series(): + error = _fails("close > 1", " param p: series = 1\n" + CLOSE) + assert "cannot be a series" in error.message + + +def test_declared_type_must_match_the_value(): + error = _fails("close > 1", " param p: bool = 5\n" + CLOSE) + assert "declared bool but its default is int" in error.message + + +def test_redeclaration_is_an_error_not_a_shadow(): + error = _fails("close > 1", CLOSE + " input close: series\n") + assert "already declared" in error.message + + +def test_unknown_type_annotation_is_rejected(): + error = _fails("close > 1", " input close: sequence\n") + assert "Unknown type 'sequence'" in error.message + + +# -- tiers -------------------------------------------------------------------- + + +def test_reasoning_constructs_require_a_higher_tier(): + # No `tier nano+` header, so escalation is out of reach. The default tier + # keeps the entry language small: reading `strategy` with no tier line tells + # an auditor there is no model in the loop. + with pytest.raises(NanoTypeError) as excinfo: + check_source( + "strategy S {\n" + " every 5m {\n" + " if RSI < 30 {\n" + ' escalate "desk"\n' + " }\n" + " }\n" + "}\n" + ) + assert "requires tier 'nano+'" in excinfo.value.message + + +def test_declaring_the_tier_admits_the_construct(): + program = check_source( + "tier nano+\n" + "strategy S {\n" + " agent Desk { role research }\n" + " every 5m {\n" + " if confidence < 0.6 {\n" + " escalate Desk\n" + " }\n" + " }\n" + "}\n" + ) + assert "llmre.escalate" in program.effects + + +def test_escalating_to_an_undeclared_agent_is_an_error(): + with pytest.raises(NanoTypeError) as excinfo: + check_source( + "tier nano+\n" + "strategy S {\n" + " every 5m {\n" + " if confidence < 0.6 {\n" + " escalate Reserch\n" + " }\n" + " }\n" + "}\n" + ) + assert "is not a declared agent" in excinfo.value.message + + +# -- risk limits -------------------------------------------------------------- + + +def test_risk_limits_are_range_checked(): + with pytest.raises(NanoTypeError) as excinfo: + check_source( + "strategy S {\n" + " risk {\n" + " max_daily_loss 2\n" + " }\n" + " every 5m {\n" + " observe()\n" + " }\n" + "}\n" + ) + # 2 would be 200% of equity. Fractions are fractions, never percentages. + assert "fraction of equity" in excinfo.value.message + + +def test_unknown_risk_limit_lists_the_valid_ones(): + with pytest.raises(NanoTypeError) as excinfo: + check_source( + "strategy S {\n" + " risk {\n" + " max_yolo 0.5\n" + " }\n" + " every 5m {\n" + " observe()\n" + " }\n" + "}\n" + ) + assert "Unknown risk limit 'max_yolo'" in excinfo.value.message + assert "max_daily_loss" in excinfo.value.message + + +def test_counting_limits_must_be_whole_numbers(): + with pytest.raises(NanoTypeError) as excinfo: + check_source( + "strategy S {\n" + " risk {\n" + " stop_trading_after_losses 2.5\n" + " }\n" + " every 5m {\n" + " observe()\n" + " }\n" + "}\n" + ) + assert "whole number" in excinfo.value.message + + +# -- effect manifest ---------------------------------------------------------- + + +def test_effects_are_derived_from_what_the_program_does(): + program = check_source(_strategy("RSI < 30")) + assert program.effects == ("intent.emit", "log.append") + + +def test_a_strategy_that_only_observes_still_declares_intent_emit(): + program = check_source("strategy S {\n every 5m {\n observe()\n }\n}\n") + assert "intent.emit" in program.effects diff --git a/tests/test_vm.py b/tests/test_vm.py new file mode 100644 index 0000000..78464c0 --- /dev/null +++ b/tests/test_vm.py @@ -0,0 +1,331 @@ +"""Indicator kernels and the v1.0 VM. + +The kernels are checked against values computed by hand rather than against +themselves, because a self-consistent indicator that is wrong is exactly the +failure mode a trading system cannot afford. Warm-up gets the same scrutiny as +the arithmetic: a fabricated early value inflates a backtest the way look-ahead +does. +""" + +import pytest + +from nano.compiler import compile_module, compile_source +from nano.indicators import evaluate, lookup +from nano.indicators.compute import ema, obv, rsi, sma, stddev, true_range +from nano.runtime.interpreter import MarketFrame, RuntimeError_, execute +from nano.runtime.vm import run_module + + +def _frame(**signals): + length = len(next(iter(signals.values()))) + return MarketFrame(timestamps=tuple(i * 60 for i in range(length)), signals=signals) + + +# -- kernels ------------------------------------------------------------------ + + +def test_sma_warms_up_then_averages(): + assert sma((1.0, 2.0, 3.0, 4.0), 3) == (None, None, 2.0, 3.0) + + +def test_ema_seeds_on_the_first_full_window(): + # alpha = 2/(3+1) = 0.5, seeded with mean(1,2,3) = 2.0. + assert ema((1.0, 2.0, 3.0, 4.0, 5.0), 3) == (None, None, 2.0, 3.0, 4.0) + + +def test_stddev_is_population_not_sample(): + # mean 2, deviations -1/0/+1 -> variance 2/3, not 1.0. + assert stddev((1.0, 2.0, 3.0), 3)[2] == pytest.approx((2 / 3) ** 0.5) + + +def test_rsi_reports_100_when_nothing_falls(): + # A window of pure gains has zero average loss; the ratio is undefined and the + # pinned convention is 100. + result = rsi((1.0, 2.0, 3.0, 4.0), 3) + assert result[:3] == (None, None, None) + assert result[3] == 100.0 + + +def test_true_range_needs_a_previous_close(): + result = true_range((10.0, 12.0), (8.0, 9.0), (9.0, 11.0)) + assert result[0] is None + assert result[1] == 3.0 # max(12-9, |12-9|, |9-9|) + + +def test_a_gap_resets_a_recursive_kernel(): + # EMA smooths across contiguous runs only. Smoothing over a hole would make the + # result depend on how the feed happened to be chunked. + assert ema((1.0, 2.0, 3.0, None, 4.0, 5.0, 6.0), 3) == ( + None, + None, + 2.0, + None, + None, + None, + 5.0, + ) + + +def test_obv_accumulates_signed_volume(): + assert obv((10.0, 11.0, 10.5), (100.0, 200.0, 300.0)) == (None, 200.0, -100.0) + + +def test_absent_cells_never_become_zero(): + assert sma((1.0, None, 3.0), 2) == (None, None, None) + + +def test_macd_warmup_matches_its_registry_rule(): + spec = lookup("MACD_HIST") + assert spec.lookback((12, 26, 9)) == 33 + values = tuple(float(i) for i in range(60)) + result = evaluate("MACD_HIST", [values, 12, 26, 9], length=60) + assert all(cell is None for cell in result[:33]) + assert result[33] is not None + + +def test_scalar_maths_broadcasts_a_constant(): + assert evaluate("MAX", [(1.0, 5.0), 3.0], length=2) == (3.0, 5.0) + + +# -- the VM ------------------------------------------------------------------- + + +def test_computed_indicator_drives_a_rule(): + module = compile_module( + "strategy S {\n" + " input close: series\n" + " let fast = SMA(close, 2)\n" + " every 1m {\n" + " if close > fast {\n" + " buy(BTC, 0.5)\n" + " }\n" + " }\n" + "}\n" + ) + # bar 0 unwarmed; bar 1 close=2 vs sma=1.5 fires; bar 2 close=1 vs 1.5 does not. + result = run_module(module, _frame(close=(1.0, 2.0, 1.0))) + assert [i.timestamp for i in result.intents] == [60] + assert result.warmup_bars_skipped == 1 + + +def test_unwarmed_bars_do_not_emit_and_are_counted(): + module = compile_module( + "strategy S {\n" + " input close: series\n" + " let slow = SMA(close, 5)\n" + " every 1m {\n" + " if slow > 0 {\n" + " buy(BTC)\n" + " }\n" + " }\n" + "}\n" + ) + result = run_module(module, _frame(close=(1.0, 2.0, 3.0))) + assert result.intents == () + assert result.warmup_bars_skipped == 3 + + +def test_else_branch_runs_when_the_condition_is_false(): + module = compile_module( + "strategy S {\n" + " every 1m {\n" + " if RSI < 30 {\n" + " buy(BTC)\n" + " } else {\n" + " observe()\n" + " }\n" + " }\n" + "}\n" + ) + result = run_module(module, _frame(RSI=(25.0, 80.0))) + assert [(i.action, i.timestamp) for i in result.intents] == [ + ("BUY", 0), + ("OBSERVE", 60), + ] + + +def test_series_offset_reads_the_previous_bar(): + module = compile_module( + "strategy S {\n" + " input close: series\n" + " every 1m {\n" + " if close > close[1] {\n" + " buy(BTC)\n" + " }\n" + " }\n" + "}\n" + ) + result = run_module(module, _frame(close=(10.0, 11.0, 9.0))) + assert [i.timestamp for i in result.intents] == [60] + + +def test_two_rules_in_one_schedule_both_evaluate(): + module = compile_module( + "strategy S {\n" + " every 1m {\n" + " if RSI < 30 {\n" + " buy(BTC)\n" + " }\n" + " if RSI > 70 {\n" + " sell(BTC)\n" + " }\n" + " }\n" + "}\n" + ) + result = run_module(module, _frame(RSI=(20.0, 50.0, 90.0))) + assert [(i.action, i.timestamp) for i in result.intents] == [ + ("BUY", 0), + ("SELL", 120), + ] + + +def test_escalation_is_recorded_with_its_reason(): + module = compile_module( + "tier nano+\n" + "strategy S {\n" + " agent Desk { role research }\n" + " every 1m {\n" + " if confidence < 0.6 {\n" + " escalate Desk\n" + " }\n" + " }\n" + "}\n" + ) + # `confidence` is injected like time and entropy -- here, from the frame. + result = run_module(module, _frame(confidence=(0.9, 0.4))) + assert [(e.target, e.timestamp, e.is_agent) for e in result.escalations] == [ + ("Desk", 60, True) + ] + + +_REASONING_SOURCE = ( + "tier nano+\n" + "strategy S {\n" + " input close: series\n" + " signature Bias {\n" + " input price: float\n" + " output score: float\n" + " }\n" + " let call = infer(Bias, close)\n" + " every 1m {\n" + " if call.score > 0.5 {\n" + " buy(BTC)\n" + " }\n" + " }\n" + "}\n" +) + + +def test_a_module_needing_reasoning_with_no_provider_emits_nothing(): + # Absent, not defaulted. A strategy that needed a model and was given none + # should produce no signal rather than a confident guess. + result = run_module(compile_module(_REASONING_SOURCE), _frame(close=(1.0, 2.0))) + assert result.intents == () + assert any(entry.event == "infer.skipped" for entry in result.log) + + +def test_an_injected_provider_makes_reasoning_replayable(): + class Recorded: + """A provider replaying a fixed transcript, which keeps the run pure.""" + + def __init__(self, scores): + self.scores = scores + + def infer(self, signature, inputs, *, timestamp): + return {"score": self.scores[timestamp]} + + module = compile_module(_REASONING_SOURCE) + frame = _frame(close=(1.0, 2.0)) + scores = {0: 0.9, 60: 0.1} + + first = run_module(module, frame, provider=Recorded(scores)) + second = run_module(module, frame, provider=Recorded(scores)) + + assert [i.timestamp for i in first.intents] == [0] + assert first.to_dict() == second.to_dict() + + +def test_module_hash_is_stable_and_excludes_itself(): + source = ( + "strategy S {\n" + " input close: series\n" + " every 1m {\n" + " if close > 1 {\n" + " buy(BTC)\n" + " }\n" + " }\n" + "}\n" + ) + module = compile_module(source) + assert module.content_hash() == compile_module(source).content_hash() + assert "moduleHash" not in module.to_dict(include_hash=False) + assert module.to_dict()["moduleHash"] == module.content_hash() + + +def test_comment_only_edits_change_source_hash_but_not_module_hash(): + # The two hashes answer different questions -- "was the file edited?" and "was + # the behavior edited?" -- and collapsing them would lose the distinction. + base = "strategy S {\n every 1m {\n observe()\n }\n}\n" + commented = "// a note\n" + base + assert compile_module(base).content_hash() == compile_module(commented).content_hash() + assert compile_module(base).source_hash != compile_module(commented).source_hash + + +def test_replay_is_bit_identical(): + module = compile_module( + "strategy S {\n" + " input close: series\n" + " let z = ZSCORE(close, 3)\n" + " every 1m {\n" + " if z > 0 {\n" + " buy(BTC, 0.7)\n" + " }\n" + " }\n" + "}\n" + ) + frame = _frame(close=(1.0, 3.0, 2.0, 8.0, 5.0)) + assert run_module(module, frame).to_dict() == run_module(module, frame).to_dict() + + +def test_vm_reports_a_missing_signal_rather_than_guessing(): + module = compile_module( + "strategy S {\n" + " every 1m {\n" + " if RSI < 30 {\n" + " buy(BTC)\n" + " }\n" + " }\n" + "}\n" + ) + with pytest.raises(RuntimeError_, match="RSI"): + run_module(module, _frame(close=(1.0,))) + + +# -- the anti-drift weld ------------------------------------------------------ + + +def test_lifted_baseline_graph_matches_the_reference_interpreter(): + """A baseline graph must behave identically through both execution paths. + + This is the guarantee that lets v1.0 exist without invalidating v0.1.0 + artifacts: the reference interpreter defines correct behavior, and the VM + reproduces it for anything baseline can express. + """ + graph = compile_source( + "strategy S {\n" + " every 15m {\n" + " if RSI(14) < 30 and Volume > 1000 {\n" + " buy(BTCUSD, 0.85)\n" + " observe()\n" + " }\n" + " }\n" + "}\n" + ) + frame = MarketFrame( + timestamps=(0, 900, 1800), + signals={"RSI": (50.0, 25.0, 20.0), "Volume": (2000.0, 2000.0, 500.0)}, + ) + reference = [i.to_dict() for i in execute(graph, frame).intents] + through_vm = [i.to_dict() for i in run_module(graph.to_module(), frame).intents] + assert reference == through_vm + assert len(reference) == 2 # both actions fire, on the middle bar only From 9bb1f0ef6f204395904f5888cb8b733a2a6ef35c Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Sun, 26 Jul 2026 08:32:44 -0400 Subject: [PATCH 2/3] harden: cover the IR load-time security boundary, fix two traceback paths A mutation sweep disabled each safety guard in turn and re-ran the suite. Six survived -- meaning nothing tested them: - effect manifest as a capability grant - tier gating - forward-reference / cycle rejection - negative series.index offset in a hand-written document - fastmath refusal - baseline tier restriction in version inference "Load-time validation is the security boundary" was an untested claim. tests/test_module.py now covers all of it (44 tests) and every mutation is killed. Two robustness defects found by chaos-testing the CLI with malformed input: - A non-UTF-8 source file produced a traceback instead of a diagnostic. UnicodeDecodeError is a ValueError, not an OSError, so the existing "except OSError" missed it: a missing file got a clean message while binary junk crashed. - The same gap existed in nano/data/frames.py for market data. load_frame now funnels every failure through FeedError, so a caller handling bad data does not also have to handle OSError and UnicodeDecodeError separately. Also: Ctrl-C now exits 130 with "interrupted" rather than a traceback, and a broken pipe (piping compile output into head) exits cleanly instead of raising during interpreter shutdown. Tests 280 -> 327 passing. Co-Authored-By: Claude Opus 5 --- nano/cli/commands.py | 14 ++ nano/cli/main.py | 26 +++- nano/data/frames.py | 24 ++- tests/test_cli.py | 23 +++ tests/test_compiler.py | 10 ++ tests/test_module.py | 329 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 420 insertions(+), 6 deletions(-) create mode 100644 tests/test_module.py diff --git a/nano/cli/commands.py b/nano/cli/commands.py index ffb2dba..5a2cfd1 100644 --- a/nano/cli/commands.py +++ b/nano/cli/commands.py @@ -13,6 +13,7 @@ | 1 | the source or the run was rejected — diagnostics printed | | 2 | the command was used wrongly (argparse's own code) | | 3 | an input could not be read | +| 130 | interrupted with Ctrl-C (the shell convention for SIGINT) | `compile` and `check` run the same pipeline and differ only in what they emit, so `nano check` passing means `nano compile` will not fail on types. Splitting them @@ -47,6 +48,7 @@ EXIT_DIAGNOSTICS = 1 EXIT_USAGE = 2 EXIT_IO = 3 +EXIT_INTERRUPTED = 130 @dataclass(frozen=True) @@ -64,11 +66,23 @@ def warn(self, message: str) -> None: def _read_source(path: Path, console: Console) -> Optional[str]: + """Read `.nano` source, or report why it could not be read. + + `UnicodeDecodeError` is caught explicitly because it is a `ValueError`, not an + `OSError` — a file of binary junk would otherwise escape as a traceback while a + missing file produced a clean diagnostic. + """ try: return path.read_text(encoding="utf-8") except OSError as exc: console.warn(f"error: cannot read {path}: {exc}") return None + except UnicodeDecodeError: + console.warn( + f"error: cannot read {path}: not valid UTF-8 " + "(Nano source must be UTF-8 encoded text)" + ) + return None def _report_compile_error(path: Path, error: NanoCompileError, console: Console) -> int: diff --git a/nano/cli/main.py b/nano/cli/main.py index 7289c8b..c52690e 100644 --- a/nano/cli/main.py +++ b/nano/cli/main.py @@ -24,12 +24,14 @@ from __future__ import annotations import argparse +import os import sys from pathlib import Path from typing import List, Optional, Sequence from ..ir.schema import SUPPORTED_IR_VERSIONS from .commands import ( + EXIT_INTERRUPTED, EXIT_OK, Console, command_check, @@ -171,7 +173,29 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return EXIT_OK console = Console(out=sys.stdout, err=sys.stderr) - return args.handler(args, console) + try: + return args.handler(args, console) + except KeyboardInterrupt: + # Ctrl-C is a decision, not a crash. 130 is the conventional + # "terminated by SIGINT" code, and printing a traceback for it would bury + # the operator's own action in noise. + console.warn("interrupted") + return EXIT_INTERRUPTED + except BrokenPipeError: + # `nano compile x.nano | head -1` closes the pipe mid-write. The command + # did its job; the reader stopped listening. Devnull the remaining stdout + # so the interpreter's shutdown flush cannot re-raise this after we return. + _silence_stdout() + return EXIT_OK + + +def _silence_stdout() -> None: + """Point stdout at the null device after a broken pipe.""" + try: + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + except OSError: # pragma: no cover - nothing useful left to do + pass def run(argv: Optional[List[str]] = None) -> None: # pragma: no cover - thin shim diff --git a/nano/data/frames.py b/nano/data/frames.py index bbd13ee..8c9e8ef 100644 --- a/nano/data/frames.py +++ b/nano/data/frames.py @@ -276,12 +276,26 @@ def load_json(path: Path, *, on_date: Optional[date] = None) -> LoadedFrame: def load_frame(path: Path, *, on_date: Optional[date] = None) -> LoadedFrame: - """Load a market frame, choosing the reader by file extension.""" + """Load a market frame, choosing the reader by file extension. + + Every failure leaves here as a ``FeedError``. A caller handling bad data + should not also have to handle `OSError` for an unreadable file and + `UnicodeDecodeError` for a binary one — those are the same problem from the + operator's point of view, and one exception type means one diagnostic path. + """ if not path.exists(): raise FeedError(f"{path} does not exist") suffix = path.suffix.lower() - if suffix == ".csv": - return load_csv(path, on_date=on_date) - if suffix == ".json": + if suffix not in (".csv", ".json"): + raise FeedError(f"Unsupported data format {suffix!r} (expected .csv or .json)") + + try: + if suffix == ".csv": + return load_csv(path, on_date=on_date) return load_json(path, on_date=on_date) - raise FeedError(f"Unsupported data format {suffix!r} (expected .csv or .json)") + except UnicodeDecodeError as exc: + raise FeedError( + f"{path} is not valid UTF-8 (market data must be UTF-8 encoded text)" + ) from exc + except OSError as exc: + raise FeedError(f"cannot read {path}: {exc}") from exc diff --git a/tests/test_cli.py b/tests/test_cli.py index e96242e..868121f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -192,6 +192,29 @@ def test_check_missing_file_is_an_io_error(tmp_path, capsys): assert "cannot read" in err +def test_non_utf8_source_is_a_diagnostic_not_a_traceback(tmp_path, capsys): + # UnicodeDecodeError is a ValueError, not an OSError, so `except OSError` alone + # let a file of binary junk escape as a traceback while a *missing* file got a + # clean message. Both are "I cannot read this". + path = tmp_path / "binary.nano" + path.write_bytes(b"\xff\xfe\x00not text") + code, _, err = _run(["check", str(path)], capsys) + assert code == EXIT_IO + assert "not valid UTF-8" in err + assert "Traceback" not in err + + +def test_non_utf8_market_data_is_a_diagnostic_not_a_traceback( + strategy, tmp_path, capsys +): + path = tmp_path / "binary.csv" + path.write_bytes(b"\xff\xfe\x00not text") + code, _, err = _run(["replay", str(strategy), "--data", str(path)], capsys) + assert code == EXIT_IO + assert "not valid UTF-8" in err + assert "Traceback" not in err + + # -- nano compile ------------------------------------------------------------- diff --git a/tests/test_compiler.py b/tests/test_compiler.py index 2e791a5..03d9b3f 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -287,6 +287,16 @@ def test_single_rule_strategies_still_emit_baseline_ir(): assert compile_to_dict(MOMENTUM_SRC)["nanoIrVersion"] == "0.1.0" +def test_a_higher_tier_forces_v1_ir_even_with_a_baseline_shaped_body(): + # Baseline IR has no `tier` field, so emitting it for a `nano+` module would + # silently drop the declaration -- and the tier is an auditable statement about + # whether a model can be in the loop, not a formatting detail. + source = "tier nano+\n" + MOMENTUM_SRC + document = compile_to_dict(source) + assert document["nanoIrVersion"] == "1.0.0" + assert document["tier"] == "nano+" + + def test_unterminated_block(): err = _compile_error("strategy S {\n every 5m {\n") assert (err.line, err.column) == (3, 1) diff --git a/tests/test_module.py b/tests/test_module.py new file mode 100644 index 0000000..41bbd87 --- /dev/null +++ b/tests/test_module.py @@ -0,0 +1,329 @@ +"""Load-time validation of v1.0 IR — the security boundary. + +`nano/ir/module.py` claims that manifest violations, tier violations, cycles, +future-reading offsets, and a broken determinism contract are *load-time +rejections, never runtime surprises*. This file is where that claim is kept +honest. + +It exists because a mutation run proved it was needed: disabling the +effect-manifest check, the tier check, the forward-reference check, the +`series.index` offset check, and the fastmath refusal each left the entire suite +green. A guard nothing tests is a guard that quietly stops working, and these are +the ones standing between a hand-written or model-generated document and the VM. + +Documents here are built as raw dicts on purpose. Going through the compiler could +only ever produce valid IR, which tests the compiler rather than the loader — and +the loader's whole job is to be the part that does not trust its input. +""" + +import pytest + +from nano.ir import NanoModule, StrategyGraph, load, load_module +from nano.ir.schema import IRValidationError, ManifestViolation, TierViolation + +BASELINE = { + "type": "Strategy", + "nanoIrVersion": "0.1.0", + "name": "S", + "effects": ["intent.emit", "log.append"], + "nodes": [ + {"type": "Schedule", "interval": "5m"}, + {"type": "Condition", "signal": "RSI", "operator": "<", "value": 30}, + {"type": "Intent", "action": "BUY"}, + ], +} + + +def document(**overrides): + """A minimal valid v1.0 document, with fields overridden per test.""" + base = { + "type": "Strategy", + "nanoIrVersion": "1.0.0", + "tier": "nano", + "name": "S", + "effects": ["log.append"], + "nodes": [{"id": "n1", "op": "const", "inputs": [], "attrs": {"value": 1}}], + "entries": [], + } + base.update(overrides) + return base + + +def _nodes(*specs): + """Build a node list from (id, op, inputs, attrs) tuples.""" + return [ + {"id": node_id, "op": op, "inputs": list(inputs), "attrs": dict(attrs)} + for node_id, op, inputs, attrs in specs + ] + + +# -- the document envelope ---------------------------------------------------- + + +def test_a_minimal_document_loads(): + module = NanoModule.from_dict(document()) + assert module.name == "S" + assert module.tier == "nano" + + +def test_round_trip_is_a_fixed_point(): + first = NanoModule.from_dict(document()) + assert NanoModule.from_dict(first.to_dict()).to_dict() == first.to_dict() + + +@pytest.mark.parametrize( + "overrides, expected", + [ + ({"type": "Loop"}, "must be 'Strategy'"), + ({"nanoIrVersion": "0.2.0"}, "unsupported by NanoModule"), + ({"name": ""}, "non-empty 'name'"), + ({"tier": "nano+++"}, "Unknown tier"), + ({"effects": []}, "non-empty 'effects'"), + ({"effects": ["intent.emit", "wire.transfer"]}, "Unknown effects declared"), + ({"nodes": []}, "non-empty 'nodes'"), + ({"warmup": -1}, "non-negative integer"), + ({"entries": ["nope"]}, "not a declared node"), + ], +) +def test_malformed_envelope_is_rejected(overrides, expected): + with pytest.raises(IRValidationError, match=expected): + NanoModule.from_dict(document(**overrides)) + + +def test_baseline_documents_are_refused_with_a_pointer_to_the_right_loader(): + with pytest.raises(IRValidationError, match="nano.ir.graph.StrategyGraph"): + NanoModule.from_dict(document(nanoIrVersion="0.1.0")) + + +# -- effects are a capability grant ------------------------------------------- + + +def test_an_intent_without_the_effect_is_a_load_time_rejection(): + """The capability boundary: a graph that never declared `intent.emit` cannot + propose an action, whatever its nodes say.""" + with pytest.raises(ManifestViolation, match=r"intent\.emit"): + NanoModule.from_dict( + document( + effects=["log.append"], + nodes=_nodes(("n1", "intent.emit", (), {"action": "BUY"})), + ) + ) + + +def test_declaring_the_effect_admits_the_node(): + module = NanoModule.from_dict( + document( + effects=["intent.emit", "log.append"], + nodes=_nodes(("n1", "intent.emit", (), {"action": "BUY"})), + ) + ) + assert module.of_op("intent.emit") + + +@pytest.mark.parametrize( + "op, attrs, effect", + [ + ("llmre.escalate", {"target": "desk"}, r"llmre\.escalate"), + ("ai.infer", {"signature": "Bias"}, r"llm\.call"), + ], +) +def test_reasoning_effects_are_gated_the_same_way(op, attrs, effect): + with pytest.raises(ManifestViolation, match=effect): + NanoModule.from_dict( + document( + tier="nano+", effects=["log.append"], nodes=_nodes(("n1", op, (), attrs)) + ) + ) + + +# -- tier gates constructs ---------------------------------------------------- + + +def test_a_nano_tier_module_cannot_contain_reasoning(): + """Reading `tier nano` should tell an auditor there is no model in the loop.""" + with pytest.raises(TierViolation, match=r"requires tier 'nano\+'"): + NanoModule.from_dict( + document( + tier="nano", + effects=["llmre.escalate", "log.append"], + nodes=_nodes(("n1", "llmre.escalate", (), {"target": "desk"})), + ) + ) + + +def test_declaring_the_tier_admits_reasoning(): + module = NanoModule.from_dict( + document( + tier="nano+", + effects=["llmre.escalate", "log.append"], + nodes=_nodes(("n1", "llmre.escalate", (), {"target": "desk"})), + ) + ) + assert module.tier == "nano+" + + +# -- the graph must be acyclic and ordered ------------------------------------ + + +def test_a_forward_reference_is_rejected(): + """Backward-only references let the VM evaluate in declaration order with no + cycle check and no way to loop forever.""" + with pytest.raises(IRValidationError, match="before it is defined"): + NanoModule.from_dict( + document( + nodes=_nodes( + ("n1", "logic.not", ("n2",), {}), + ("n2", "const", (), {"value": True}), + ) + ) + ) + + +def test_a_self_reference_is_rejected(): + with pytest.raises(IRValidationError, match="before it is defined"): + NanoModule.from_dict(document(nodes=_nodes(("n1", "logic.not", ("n1",), {})))) + + +def test_duplicate_node_ids_are_rejected(): + with pytest.raises(IRValidationError, match="Duplicate node id"): + NanoModule.from_dict( + document( + nodes=_nodes( + ("n1", "const", (), {"value": 1}), + ("n1", "const", (), {"value": 2}), + ) + ) + ) + + +def test_unknown_opcodes_are_rejected(): + with pytest.raises(IRValidationError, match="Unknown node op"): + NanoModule.from_dict(document(nodes=_nodes(("n1", "order.submit", (), {})))) + + +def test_wrong_operand_count_is_rejected(): + with pytest.raises(IRValidationError, match="takes 2 input"): + NanoModule.from_dict( + document( + nodes=_nodes( + ("n1", "const", (), {"value": 1}), + ("n2", "compare.lt", ("n1",), {}), + ) + ) + ) + + +# -- look-ahead cannot be reintroduced by hand -------------------------------- + + +def test_a_negative_offset_in_a_handwritten_document_is_rejected(): + """The compiler cannot emit this, so reaching it means a hand-edited or + model-generated document is trying to read the future. The loader is the last + line before the VM.""" + with pytest.raises(IRValidationError, match="non-negative integer 'offset'"): + NanoModule.from_dict( + document( + nodes=_nodes( + ("n1", "feed.signal", (), {"name": "close"}), + ("n2", "series.index", ("n1",), {"offset": -1}), + ) + ) + ) + + +def test_a_non_integer_offset_is_rejected(): + with pytest.raises(IRValidationError, match="non-negative integer 'offset'"): + NanoModule.from_dict( + document( + nodes=_nodes( + ("n1", "feed.signal", (), {"name": "close"}), + ("n2", "series.index", ("n1",), {"offset": 1.5}), + ) + ) + ) + + +def test_a_zero_offset_is_the_current_bar_and_is_fine(): + module = NanoModule.from_dict( + document( + nodes=_nodes( + ("n1", "feed.signal", (), {"name": "close"}), + ("n2", "series.index", ("n1",), {"offset": 0}), + ) + ) + ) + assert module.node("n2").attrs["offset"] == 0 + + +# -- the determinism contract ------------------------------------------------- + + +def test_fastmath_is_refused_outright(): + """There is no flag to accept it. A module whose numbers drift between runs + cannot honour any of Nano's other guarantees.""" + with pytest.raises(IRValidationError, match="breaks bit-identical replay"): + NanoModule.from_dict( + document( + determinism={ + "clock": "injected", + "entropy": "injected", + "fastmath": True, + } + ) + ) + + +@pytest.mark.parametrize("key", ["clock", "entropy"]) +def test_ambient_clock_or_entropy_is_refused(key): + contract = {"clock": "injected", "entropy": "injected", "fastmath": False} + contract[key] = "ambient" + with pytest.raises(IRValidationError, match=f"determinism.{key}"): + NanoModule.from_dict(document(determinism=contract)) + + +# -- attribute validation per opcode ------------------------------------------ + + +@pytest.mark.parametrize( + "op, attrs, expected", + [ + ("intent.emit", {"action": "HODL"}, "expected one of"), + ("intent.emit", {"action": "BUY", "confidence": 1.5}, r"within \[0, 1\]"), + ("intent.emit", {"action": "BUY", "asset": ""}, "non-empty string"), + ("schedule", {}, "non-empty 'interval'"), + ("feed.signal", {}, "non-empty 'name'"), + ("agent", {"name": "A", "role": "overlord"}, "unknown agent role"), + ("indicator", {"name": "EMA", "periods": [0]}, "positive integers"), + ("indicator", {"name": "EMA", "lookback": -1}, "non-negative integer"), + ("risk.limits", {"limits": {}}, "non-empty 'limits'"), + ("risk.limits", {"limits": {"max_yolo": 1}}, "unknown risk limit"), + ("risk.limits", {"limits": {"max_daily_loss": "big"}}, "must be numeric"), + ("const", {}, "requires a 'value'"), + ], +) +def test_opcode_attributes_are_validated(op, attrs, expected): + effects = ["intent.emit", "log.append"] if op == "intent.emit" else ["log.append"] + with pytest.raises(IRValidationError, match=expected): + NanoModule.from_dict( + document(effects=effects, nodes=_nodes(("n1", op, (), attrs))) + ) + + +# -- version dispatch --------------------------------------------------------- + + +def test_load_dispatches_on_version(): + assert isinstance(load(BASELINE), StrategyGraph) + assert isinstance(load(document()), NanoModule) + + +def test_load_module_lifts_baseline_so_runtimes_need_one_path(): + module = load_module(BASELINE) + assert isinstance(module, NanoModule) + assert module.provenance["liftedFrom"] == "0.1.0" + assert module.entries # the rule survived the lift + + +def test_an_unknown_version_is_rejected_rather_than_guessed(): + with pytest.raises(IRValidationError, match="unsupported"): + load({"type": "Strategy", "nanoIrVersion": "9.9.9", "nodes": []}) From 8c1de757b5e674673e8236754fdf5609f83e757e Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Sun, 26 Jul 2026 08:50:56 -0400 Subject: [PATCH 3/3] harden: enforce the IR trust boundary, fix 9 CLI defects, stop doc drift Production polish over v1.0. No new language features. Full report in docs/HARDENING-2026-07-26.md. Tests 280 -> 338. Governance -- the boundary was reachable around ----------------------------------------------- NanoModule is a frozen dataclass, so in-process code could construct one and skip from_dict entirely. An audit built a module whose manifest granted only log.append and ran three BUY intents through the VM; the identical document, loaded properly, is rejected. NanoModule.validate() now re-runs load-time validation and run_module calls it before executing -- one pass over the nodes against an evaluation that is nodes x bars. Two asymmetries closed: - compile_to_dict did not round-trip through the loader while compile_module did, so "nano compile -o ir.json" wrote a document the validator never saw. That contradicted a comment in the same file. - StrategyGraph.to_module() built a module directly; now validated too. Effect manifests reject duplicate entries: two byte-different documents granting the same capability would make moduleHash depend on spelling. A mutation sweep disabled each safety guard in turn. Six survived -- nothing tested them. tests/test_module.py now covers all of it and every mutation dies. CLI correctness --------------- - replay and visualize caught only NanoCompileError, but compile_module round-trips through the loader -- IRValidationError escaped as a traceback while compile reported it cleanly. One shared _compile helper now. - --emit types and --emit plan accepted -o, ignored it, and exited 0 having written nothing. - check abandoned remaining files at the first unreadable one, so the exit code depended on argument order. - the --verify second run sat outside the error guard. - exit codes reclassified: a malformed --date, an unknown indicator, and an impossible --ir-version are usage errors (2); a signal the data lacks and an empty date selection are diagnostics (1), since both inputs read fine. - "nano indicators" with an empty name printed the whole list -- truthiness where identity belonged. Simplification -------------- - _is_plain_int was defined twice, byte-identical including its docstring. - the comparison-operator set was written out four times; one source now. - _NAMED_OPS existed in two files. - removed canonical_effects() and the CompiledIR alias -- no call sites. Documentation ------------- CONTRIBUTING said the shipped CLI was "designed but not built", inviting a contributor to rebuild working code. Three documented examples had not compiled for several releases: README's showcase used "observe market", its roadmap prose used "buy when RSI < 30", and BUILD_ORDER's exit criterion used "buy()". tests/test_docs.py now compiles every fenced nano block in the repository and checks the advertised test count against the suite, so this class of drift fails CI. Blocks opt out with an explicit "// doc: illustrative" marker. Also corrected: stale counts (including a 121 the last sweep missed), RiskEngine -> DecisionGate, ProvenanceRiskEngine -> ProvenanceGate, and the status tables. No doc claims broker execution, live feeds, a loop runner, self-modifying deployment, or quantum dispatch -- none of those were built. Robustness ---------- A non-UTF-8 source file crashed instead of reporting: UnicodeDecodeError is a ValueError, not an OSError, so a missing file got a clean diagnostic while binary junk produced a stack trace. Same gap in nano/data/frames.py. Ctrl-C now exits 130 with "interrupted"; a broken pipe exits cleanly. Co-Authored-By: Claude Opus 5 --- BUILD_ORDER.md | 10 +- CONTRIBUTING.md | 13 +- README.md | 49 ++++--- docs/HARDENING-2026-07-26.md | 239 ++++++++++++++++++++++++++++++++++ docs/papers/01-why-nano.md | 2 +- docs/papers/11-performance.md | 2 +- docs/papers/README.md | 2 +- examples/README.md | 4 +- nano/cli/commands.py | 221 ++++++++++++++++++++++--------- nano/cli/render.py | 5 +- nano/compiler/legacy.py | 5 +- nano/compiler/parser.py | 5 +- nano/ir/__init__.py | 2 - nano/ir/module.py | 38 ++++-- nano/runtime/vm.py | 40 ++++-- nano/types/checker.py | 17 ++- nano/types/lookahead.py | 14 +- tests/test_cli.py | 32 ++++- tests/test_docs.py | 121 +++++++++++++++++ tests/test_module.py | 68 ++++++++++ 20 files changed, 747 insertions(+), 142 deletions(-) create mode 100644 docs/HARDENING-2026-07-26.md create mode 100644 tests/test_docs.py diff --git a/BUILD_ORDER.md b/BUILD_ORDER.md index 7d0dda7..273bec3 100644 --- a/BUILD_ORDER.md +++ b/BUILD_ORDER.md @@ -39,7 +39,7 @@ requires `intent.emit` in the module's effect manifest. | `Schedule(interval)` | when the graph evaluates ("5m") | | `Condition(signal, operator, value)` | e.g. RSI < 30 | | `Intent(action, asset, confidence)` | proposal, never an order | -| `Agent(name)` | later — named behavior blocks | +| `Agent(name)` | named behavior blocks; `agent N { role research }` | Target: this JSON **is** a runnable strategy: @@ -95,10 +95,10 @@ First execution integration. Flow: `Nano IR → bridge (host-platform adapter) execution decision`. The bridge loads IR, verifies the effect manifest, streams recorded market frames through the interpreter, and forwards intents into the host platform's risk/release-gate discipline. The backtester runs the same IR against historical frames — bit-identical replay is -the acceptance test. The reference adapter here defines the `RiskEngine` protocol any platform +the acceptance test. The reference adapter here defines the `DecisionGate` protocol any platform can implement; Aether ATS is the first consumer. -An optional `ProvenanceRiskEngine` (`nano/bridge/provenance.py`) wraps any `RiskEngine` to bind +An optional `ProvenanceGate` (`nano/bridge/provenance.py`) wraps any `DecisionGate` to bind each decision to a signed, independently verifiable receipt — for platforms that need non-repudiable proof a decision happened, not just a log line. Fully outside the language: a `.nano` author can't see or reach it. Requires the optional `provenance` extra @@ -107,7 +107,7 @@ non-repudiable proof a decision happened, not just a log line. Fully outside the ### Milestone 6 — Editor tooling ✅ engine layer (`nano/aethercode/`; extension packaging pending) Not a whole IDE. The pure language-service engine first: syntax highlighting (semantic tokens), -diagnostics, IR preview (`when RSI < 30` → shows the compiled ConditionNode). Packaged as a +diagnostics, IR preview (`if RSI < 30 { … }` → shows the compiled ConditionNode). Packaged as a VS-Code-style extension by the host editor. ### Milestone 7 — Host-platform compiler inputs @@ -128,7 +128,7 @@ graph is proven. | Week | Build | Exit | |---|---|---| | 1 | Nano package, IR schema, JSON serialization, basic interpreter | A JSON strategy executes deterministically | -| 2 | Lexer/parser, `.nano` files compile to IR | `if RSI < 30 { buy() }` works end-to-end | +| 2 | Lexer/parser, `.nano` files compile to IR | `if RSI < 30 { buy(BTC) }` works end-to-end | | 3 | Risk-gate bridge, risk-layer hand-off, backtester | Nano strategies simulate against a host risk engine | | 4 | Editor extension: highlighting + IR visualizer | Developer edits Nano with live IR preview | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a6bbb7..cc9d8f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,8 +25,17 @@ bit-for-bit). This teaches you the whole pipeline in one PR. **The conformance corpus.** New `.nano` programs in [`nano/examples/`](nano/examples/) that exercise untested language shapes — each is source + hand-written IR that must match exactly. -**The CLI.** `nano compile` / `nano replay` / `nano visualize` are designed but not built. -See [BUILD_ORDER.md](BUILD_ORDER.md) for the intended shape. +**Indicator kernels.** New entries in [`nano/indicators/`](nano/indicators/) — a signature in +`registry.py` (parameter kinds plus a warm-up rule) and a deterministic kernel in `compute.py`. +Pin the convention for every degenerate case (zero dispersion, a flat range, a zero divisor) in +that module's docstring table: an unpinned convention is a silent divergence between two +runtimes. + +**Type-checker diagnostics.** Anywhere [`nano/types/`](nano/types/) rejects a program with a +message that does not say what *would* have been valid. + +**CLI ergonomics.** `nano check / compile / replay / visualize / indicators / version` ship in +[`nano/cli/`](nano/cli/). Rough edges and missing flags are fair game. **Docs and papers.** Anything in [`docs/papers/`](docs/papers/) that is unclear, overstated, or wrong — issues and PRs both welcome. The house rule: every claim cites shipped code or is diff --git a/README.md b/README.md index 5f471d8..ebb26f8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![CI](https://github.com/DBarr3/Nano/actions/workflows/ci.yml/badge.svg)](https://github.com/DBarr3/Nano/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-22d3ee.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.10%2B-38bdf8.svg)](pyproject.toml) -[![Tests](https://img.shields.io/badge/tests-173%20passing-22c55e.svg)](tests) +[![Tests](https://img.shields.io/badge/tests-338%20passing-22c55e.svg)](tests) [![Made by Aether AI](https://img.shields.io/badge/made%20by-Aether%20AI-0ea5e9.svg)](https://aethersystems.net) [![GitHub Repo stars](https://img.shields.io/github/stars/DBarr3/Nano?style=social)](https://github.com/DBarr3/Nano/stargazers) @@ -29,9 +29,9 @@ > > Nano compiles reasoning into execution graphs that can **run, replay, audit, and improve**. -> **Status:** research preview · Milestones 1–6 shipped · **173 tests passing**. The IR, compiler, -> interpreter, risk-gate bridge, and optimization loop are real and tested; the CLI and -> `Series` typing are still design. [Full status ↓](#status--whats-real) +> **Status:** v1.0.0 · **338 tests passing**. The IR, compiler, +> interpreter, risk-gate bridge, and optimization loop are real and tested; the CLI, static typing, and +> look-ahead protection are shipped. [Full status ↓](#status--whats-real) ## What is Nano? @@ -151,19 +151,25 @@ deterministically; unknown states escalate back to reasoning. ```nano strategy Momentum { - every 5m { + input close: series + input volume: series - observe market + let oversold = RSI(close, 14) + let avgVolume = SMA(volume, 20) - if RSI(14) < 30 - and volume > average { + every 5m { - execute() + if oversold < 30 and volume > avgVolume { + buy(BTCUSD, 0.85) + } else { + observe() } } } ``` +Every `.nano` block in this README compiles. CI runs `nano check` over them. + What happens to that file: ``` @@ -249,7 +255,7 @@ language. |---|---| | Deterministic replay | No ambient clock or RNG — time and entropy are injected, logged inputs | | Strategies can't bypass risk | No exchange API in the language; programs emit intents, the runtime disposes | -| No look-ahead in backtests | `Series` types make peeking at the future a compile error *(design)* | +| No look-ahead in backtests | series offsets count backwards and must fold to a non-negative constant, so `close[t+1]` is a compile error | | Least-privilege execution | Every IR module carries an effect manifest — a capability boundary | | Reproducible builds | Content-addressed IR, pinned package hashes | | Gated self-improvement | AI-compiled workflows pass admission gates before any runtime loads them | @@ -259,7 +265,7 @@ threads, and mutable globals — each one destroys replayability. ## Nano architecture roadmap -Three conceptual tiers over one compiler and one IR. A user starts with `buy when RSI < 30` +Three conceptual tiers over one compiler and one IR. A user starts with `if RSI < 30 { buy(BTC) }` and never has to leave the language as their agents grow. | Tier | Question it answers | State | @@ -272,10 +278,10 @@ and never has to leave the language as their agents grow. Shipped → IR · compiler · deterministic interpreter · risk-gate bridge backtester · pattern memory · editor services · optimization loop -Next → CLI (nano compile / replay / visualize) - Series look-ahead typing (no-peek backtests as a compile error) +Next → Multi-agent coordination and a concrete reasoning provider + Live market-data adapters and broker execution (host-side) -Then → Nano+ adaptive layer — memory, confidence routing, multi-agent +Then → Autonomous loop runner · self-modifying deployment (gated) Real quantum-hardware dispatch (research) ``` @@ -312,7 +318,7 @@ compiles behavior *ahead of time* into a replayable IR — the model is never th [Paper 01](docs/papers/01-why-nano.md) for the full landscape comparison. **Is it production-ready?** -It is a research preview with 173 passing tests. The core (IR, compiler, interpreter, bridge) +v1.0.0, with 338 passing tests. The core (IR, compiler, interpreter, bridge) is real and tested; the CLI and several typing features are still design. The [status table](#status--whats-real) is kept honest. @@ -335,11 +341,14 @@ Start with these four; they carry the whole thesis: | Shipped (tested) | Design / roadmap / research | |---|---| -| Nano IR (`nano/ir/`) | CLI (`nano compile` / `replay` / `visualize`) | -| Reference interpreter (`nano/runtime/`) | `Series` look-ahead typing | -| `.nano` → IR compiler (`nano/compiler/`) | Nano+ adaptive layer (memory, multi-agent) | -| Risk-gate bridge + backtester (`nano/bridge/`) | Real quantum-hardware dispatch | -| Editor services (`nano/aethercode/`) | Cognitive execution / confidence routing | +| Nano IR, both versions (`nano/ir/`) | Live market feeds and broker execution | +| Reference interpreter + VM (`nano/runtime/`) | Autonomous loop runner | +| `.nano` → IR compiler (`nano/compiler/`) | Multi-agent coordination | +| Static typing + look-ahead protection (`nano/types/`) | Self-modifying deployment | +| 33 indicators (`nano/indicators/`) | Real quantum-hardware dispatch | +| CLI (`nano/cli/`) · data adapters (`nano/data/`) | | +| Risk-gate bridge + backtester (`nano/bridge/`) | | +| Editor services (`nano/aethercode/`) | | | Pattern cache (`nano/memory/`) + optimization loop (`nano/loop/`) | | | Conformance corpus (`nano/examples/`) + strategy library (`nano/library/`) | | diff --git a/docs/HARDENING-2026-07-26.md b/docs/HARDENING-2026-07-26.md new file mode 100644 index 0000000..382510a --- /dev/null +++ b/docs/HARDENING-2026-07-26.md @@ -0,0 +1,239 @@ +# Pre-production hardening sweep — 2026-07-26 + +A polish pass over the Nano v1.0 implementation. No new language features, no +spec expansion. The objective was to find the places where the code did not yet +do what the code said it did. + +Four read-only audits ran in parallel — structural drift, governance boundaries, +diagnostic and CLI consistency, documentation drift — followed by a mutation +sweep that disabled each safety guard in turn to see whether any test noticed. + +**Tests: 280 → 338.** Every finding below was verified by execution, not by +inspection. + +--- + +## The finding that mattered + +`nano/ir/module.py` opens by claiming that manifest violations, tier violations, +cycles, and future-reading offsets are *"enforced at load time, not discovered at +run time"*. The mutation sweep disabled each of those checks and re-ran the full +suite. **Six survived**: + +| Disabled guard | Suite result | +|---|---| +| effect manifest as a capability grant | 280 passed | +| tier gating | 280 passed | +| forward-reference / cycle rejection | 280 passed | +| negative `series.index` offset | 280 passed | +| fastmath refusal | 280 passed | +| baseline tier restriction in version inference | 280 passed | + +"Load-time validation is the security boundary" was an untested assertion. The +checks existed and worked; nothing would have told us if they stopped working. +`tests/test_module.py` now covers all of it, and every one of those mutations is +killed. + +Worse, the boundary was **reachable around**. `NanoModule` is a frozen dataclass, +so in-process code could construct one directly and skip `from_dict` entirely — +and the governance audit did exactly that, building a module whose manifest +granted only `log.append` and running three `BUY` intents through the VM. The +identical document, loaded properly, is rejected. + +Fixed by making the claim true rather than softening it: `NanoModule.validate()` +re-runs load-time validation, and `run_module` calls it before executing. One +pass over the nodes, against an evaluation that is nodes × bars. + +--- + +## Governance + +| Claim | Verdict | +|---|---| +| A Nano program cannot act on the world | **Holds, structurally.** The entire stdlib surface of `nano/` is `argparse, csv, hashlib, json, re, sys, os, dataclasses, datetime, pathlib, typing`. No socket, no subprocess, no `eval`, no `pickle`. Enforced by absence. | +| Effects are a capability grant | **Was bypassable** via direct construction → fixed by `validate()`. | +| The runtime cannot execute invalid IR | **Was false** for two paths → fixed. | +| Tier gates constructs | Holds at both compile time and load time. | +| No look-ahead | Holds at three independent layers, now tested at each. | +| fastmath refused; clock and entropy injected | Holds. No ambient time or randomness anywhere in `nano/`. | +| The CLI cannot skip safety checks | Holds. Every command routes through `check_source` / `compile_module` / `compile_to_dict`. | + +Two asymmetries closed: + +- **`compile_to_dict` did not round-trip through the loader**, while + `compile_module` did — so `nano compile -o ir.json` wrote a document the + validator had never seen. This contradicted a comment in that same file: + *"A compiler that trusts its own output is a compiler whose invariants drift."* +- **`StrategyGraph.to_module()`** built a `NanoModule` directly. Now validated + through the same path as everything else. + +--- + +## Robustness + +Chaos-testing the CLI with malformed input found two traceback paths: + +- A **non-UTF-8 source file** crashed instead of reporting. `UnicodeDecodeError` + is a `ValueError`, not an `OSError`, so `except OSError` missed it — a *missing* + file got a clean diagnostic while binary junk produced a stack trace. +- The same gap existed in `nano/data/frames.py` for market data. `load_frame` now + funnels every failure through `FeedError`, so a caller handling bad data does + not also have to handle two unrelated exception types. + +Also: **Ctrl-C** exits 130 with `interrupted` rather than a traceback, and a +**broken pipe** (`nano compile x.nano | head -1`) exits cleanly instead of raising +during interpreter shutdown. + +Traced and verified clean: permission errors, a directory passed as a file, empty +files, empty CSVs, malformed dates, unknown flags, unknown subcommands. + +--- + +## CLI correctness + +| Defect | Fix | +|---|---| +| `replay` and `visualize` caught only `NanoCompileError`, but `compile_module` round-trips through the loader — so `IRValidationError` escaped as a traceback while `compile` reported it cleanly | one shared `_compile` helper; all three behave identically | +| `--emit types` and `--emit plan` **accepted `-o`, ignored it, and exited 0 having written nothing** | every emit mode routes through `_write_or_print` | +| `check` abandoned remaining files at the first unreadable one, so **the exit code depended on argument order** | every file is attempted; a rejected program outranks an unreadable one | +| the `--verify` second run sat outside the error guard | both runs share it | +| a malformed `--date` cost a full compile before erroring | parsed first | + +Exit codes reclassified to mean something: + +| Situation | Was | Now | Why | +|---|---|---|---| +| malformed `--date` | 3 (IO) | 2 (usage) | nothing failed to be read | +| unknown indicator name | 1 | 2 (usage) | a bad argument value | +| forced IR version cannot hold the program | 1 | 2 (usage) | the program is fine; the flag was wrong | +| data file lacks a required signal | 3 (IO) | 1 | both inputs read fine; they do not fit each other | +| no rows for the requested date | 3 (IO) | 1 | the read succeeded | + +`nano indicators ""` used to print the entire list — a truthiness check where an +identity check belonged. + +--- + +## Simplification + +| Duplication | Resolution | +|---|---| +| `_is_plain_int` defined twice, byte-identical including its docstring | one public `is_plain_int` in `nano/types/lookahead.py` | +| the comparison-operator set written out four times | one source: `CONDITION_OPERATORS` in `nano/ir/schema.py` | +| `_NAMED_OPS` defined in both `nano/ir/module.py` and `nano/cli/render.py` | one exported `NAMED_OPS` | +| `canonical_effects()` — never called from any path | removed | +| `CompiledIR` type alias — never used | removed | + +Also added: duplicate entries in an effect manifest are now rejected. Two +byte-different documents granting the same capability would otherwise make +`moduleHash` depend on manifest spelling rather than meaning. + +--- + +## Documentation + +The docs described the previous release. The worst offender was +`CONTRIBUTING.md`, which told contributors that *"`nano compile` / `nano replay` / +`nano visualize` are designed but not built"* — inviting someone to rebuild +shipped, tested code. + +Three documented examples did not compile, and had not for several releases: + +| Location | Problem | +|---|---| +| `README.md` showcase | `observe market` — the grammar requires `observe()`; `average` was undefined | +| `README.md` roadmap prose | `buy when RSI < 30` — `when` is only legal inside a `route` block | +| `BUILD_ORDER.md` exit criterion | `buy()` — an asset is required | + +Rather than only fix them, `tests/test_docs.py` now **compiles every fenced +`nano` block in the repository** and checks the advertised test count against the +suite. A block may opt out with `// doc: illustrative`, which is explicit and +greppable. Documentation drift of this kind is now a test failure. + +Corrected throughout: stale counts (including a `121` the 173→280 sweep would +have missed), `RiskEngine` → `DecisionGate`, `ProvenanceRiskEngine` → +`ProvenanceGate`, and the status tables moving the CLI, static typing, look-ahead +protection, and indicators out of "roadmap". + +**Deliberately not softened:** no document claims broker execution, live market +feeds, order execution, an autonomous loop runner, self-modifying deployment, or +real quantum dispatch as built — because none of them are. + +--- + +## Performance + +Nothing was optimised, because nothing measured slow: the full suite runs in +about one second, and the audit found no repeated parsing, redundant AST cloning, +or duplicated validation passes on a hot path. + +One change goes marginally the other way and is worth stating. `run_module` now +validates before executing, which is one pass over the nodes per call. +`run_frames` validates once for the whole sequence rather than once per frame. +Against an evaluation that is nodes × bars, the check is noise — and buying a real +security boundary with it is the right trade. + +--- + +## Remaining technical debt + +Named rather than quietly carried. + +**Six files exceed 500 lines**, against a house guideline of 800 max and 200–400 +typical: + +| File | Lines | +|---|---| +| `nano/types/checker.py` | 945 | +| `nano/compiler/parser.py` | 877 | +| `nano/indicators/compute.py` | 631 | +| `nano/ir/module.py` | 594 | +| `nano/compiler/codegen.py` | 583 | +| `nano/runtime/vm.py` | 548 | + +`checker.py` is the one worth splitting: declaration handling, expression typing, +and call resolution are three separable passes sharing only the scope. Deferred +because a 945-line refactor at the end of a hardening pass trades a known-good +state for a rushed one. `compute.py`'s length is 33 independent kernels and is +fine as it stands. + +**Diagnostic wording is not yet uniform.** The audit catalogued real +inconsistencies: five messages in `nano/types/checker.py` start with a lowercase +identifier where every other message leads with a capitalised construct name; +`sorted(X)` in five places leaks Python list syntax into user-facing text +(`expected one of ['BUY', 'EXECUTE', …]`); one construct is called "series +offset", "offset", and "index" in different files. Two messages in `checker.py` +raise at position `(0, 0)`, which is not a 1-based position and so violates the +invariant `nano/compiler/errors.py` states. All cosmetic except the last, and a +wholesale rewording pass is churn better done on its own. + +**`nano/ir/*` errors carry no source position.** The AST position exists at +lowering time and is discarded, so a loader rejection surfaced through +`nano compile` names a node id rather than a line. Threading positions into the IR +is a real improvement and a real change, not a polish item. + +**A negative `series.index` offset that somehow reached the VM would raise +`IndexError`**, surfacing as `replay failed: tuple index out of range` rather than +as a look-ahead diagnostic. It cannot silently read forward — the failure mode is a +crash, never an optimistic backtest — but the message should name the cause. + +**LOOP-14 and LOOP-15 did not run.** Both require a governance ledger +(`_loopstate/governance-ledger.md`) and a benchmark suite this repository does not +have. Reporting trends from absent data would have been fabrication. + +--- + +## Audited and found clean + +No `TODO`, `FIXME`, `XXX`, `HACK`, `breakpoint()`, or `pdb` anywhere in `nano/`. +No commented-out code. No `print()` outside the `Console` class. No debug logging. +No unused dependencies — the package still installs with zero mandatory +dependencies. stdout/stderr discipline is consistent: machine-readable output to +stdout, diagnostics and progress to stderr. + +One genuinely swallowed error remains, and it predates this work. +`nano/aethercode/preview.py` catches `Exception` and renders diagnostics, so a +compiler *bug* — as opposed to a source error — becomes an empty string in the +editor service rather than a report. Its stated invariant is only "never raises", +which it honours. Flagged, not fixed: narrowing it changes behaviour Aether Code +depends on, and that deserves its own change. diff --git a/docs/papers/01-why-nano.md b/docs/papers/01-why-nano.md index 8b978cd..8193b7c 100644 --- a/docs/papers/01-why-nano.md +++ b/docs/papers/01-why-nano.md @@ -97,7 +97,7 @@ Nano's first workload is trading strategy execution — not because trading is t ## What exists today -Honestly stated: the IR, the deterministic reference interpreter, the pattern memory layer, the `.nano` → IR compiler (`nano/compiler/`), the risk-gate bridge and backtester (`nano/bridge/`), the strategy library (`nano/library/`), editor language services (`nano/aethercode/`), and the Nano++ optimization loop with a deterministic quantum simulator backend (`nano/loop/`) are implemented, covered by 173 passing tests. Every example compiles from `.nano` source to bit-identical IR and replays deterministically. The CLI (`nano compile` / `nano replay` / `nano visualize`) and real quantum-hardware dispatch remain design and research, tracked in [BUILD_ORDER.md](../../BUILD_ORDER.md) and paper [14](14-future-work.md). Comparative claims about other frameworks above describe their documented designs, not benchmarks we have run. +Honestly stated: the IR, the deterministic reference interpreter, the pattern memory layer, the `.nano` → IR compiler (`nano/compiler/`), the risk-gate bridge and backtester (`nano/bridge/`), the strategy library (`nano/library/`), editor language services (`nano/aethercode/`), and the Nano++ optimization loop with a deterministic quantum simulator backend (`nano/loop/`) are implemented, covered by 250 passing tests. Every example compiles from `.nano` source to bit-identical IR and replays deterministically. The CLI (`nano compile` / `nano replay` / `nano visualize`) and real quantum-hardware dispatch remain design and research, tracked in [BUILD_ORDER.md](../../BUILD_ORDER.md) and paper [14](14-future-work.md). Comparative claims about other frameworks above describe their documented designs, not benchmarks we have run. --- diff --git a/docs/papers/11-performance.md b/docs/papers/11-performance.md index 4f7a074..5da3ae7 100644 --- a/docs/papers/11-performance.md +++ b/docs/papers/11-performance.md @@ -22,7 +22,7 @@ Three shipped components define the execution profile: ## What is honestly unmeasured -The repository currently ships correctness tests (121 of them), not benchmarks. The following numbers do not exist yet, and no document in this series should be read as implying them: +The repository currently ships correctness tests (338 of them), not benchmarks. The following numbers do not exist yet, and no document in this series should be read as implying them: - Interpreter throughput (ticks/second) on realistic strategy graphs and frame sizes. - Pattern retrieval latency as a function of store size. diff --git a/docs/papers/README.md b/docs/papers/README.md index 1fa0e07..d2d416e 100644 --- a/docs/papers/README.md +++ b/docs/papers/README.md @@ -46,4 +46,4 @@ Seventeen short papers, each answering one question about Nano — the compiled --- -Grounding: papers cite the shipped implementation (`nano/ir/`, `nano/runtime/`, `nano/memory/`, `nano/compiler/`, `nano/bridge/`, `nano/library/`, `nano/aethercode/`, `nano/loop/` — 173 tests) and mark everything else as design, roadmap, or research. Build sequence: [BUILD_ORDER.md](../../BUILD_ORDER.md). +Grounding: papers cite the shipped implementation (`nano/ir/`, `nano/runtime/`, `nano/memory/`, `nano/compiler/`, `nano/bridge/`, `nano/library/`, `nano/aethercode/`, `nano/loop/` — 338 tests) and mark everything else as design, roadmap, or research. Build sequence: [BUILD_ORDER.md](../../BUILD_ORDER.md). diff --git a/examples/README.md b/examples/README.md index 523e29b..1962b27 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,8 +8,8 @@ pieces together. ## provenance_signing_demo.py -Wraps a toy risk engine with [`ProvenanceRiskEngine`](../nano/bridge/provenance.py) -so every risk-gate decision — approved or rejected — is signed via +Wraps a toy risk engine with [`ProvenanceGate`](../nano/bridge/provenance.py) +so every decision-gate decision — approved or rejected — is signed via [Protocol-C](https://github.com/DBarr3/PROTOCOL-C) into an append-only, independently verifiable audit log, with zero changes to the strategy or the decision itself. diff --git a/nano/cli/commands.py b/nano/cli/commands.py index 5a2cfd1..9c38e10 100644 --- a/nano/cli/commands.py +++ b/nano/cli/commands.py @@ -31,6 +31,7 @@ from typing import Any, List, Optional, Sequence, TextIO from ..compiler import ( + IRVersionError, NanoCompileError, check_source, compile_module, @@ -39,10 +40,11 @@ ) from ..data import FeedError, load_frame, parse_date from ..indicators.registry import INDICATORS, names as indicator_names -from ..ir.schema import SUPPORTED_IR_VERSIONS +from ..ir.schema import SUPPORTED_IR_VERSIONS, IRValidationError +from ..runtime.interpreter import RuntimeError_ from ..runtime.vm import run_module from ..types.env import KIND_FEED, KIND_INPUT, KIND_LET, KIND_PARAM -from .render import FORMATS, render, summarise_run +from .render import render, summarise_run EXIT_OK = 0 EXIT_DIAGNOSTICS = 1 @@ -90,18 +92,55 @@ def _report_compile_error(path: Path, error: NanoCompileError, console: Console) return EXIT_DIAGNOSTICS +class _Rejected(Exception): + """A command's input was rejected. Carries the exit code to return.""" + + def __init__(self, code: int) -> None: + super().__init__(code) + self.code = code + + +def _compile(path: Path, source: str, console: Console): + """Compile to a module, reporting every rejection as a diagnostic. + + ``compile_module`` round-trips through the IR loader, so it can raise + ``IRValidationError`` as well as ``NanoCompileError``. Catching only the + latter — which every command used to do — turned a loader rejection into a + traceback in `replay` and `visualize` while `compile` reported it cleanly. + """ + try: + return compile_module(source) + except NanoCompileError as error: + raise _Rejected(_report_compile_error(path, error, console)) from error + except IRValidationError as error: + # No source position: the IR contract was violated after lowering, so the + # locator is the node id the loader names, not a line. + console.warn(f"{path}: error: {error}") + raise _Rejected(EXIT_DIAGNOSTICS) from error + + # --------------------------------------------------------------------------- # nano check # --------------------------------------------------------------------------- def command_check(args: Any, console: Console) -> int: - """Type-check one or more files. Silent on success, like a linter.""" + """Type-check one or more files. Silent on success, like a linter. + + Every file is attempted even after one fails. Returning at the first + unreadable file made the exit code depend on argument order — `check bad.nano + missing.nano` reported an I/O error while `check bad.nano ok.nano` reported a + diagnostic — and hid the remaining files from anyone running this over a + directory. + """ failed = 0 + unreadable = 0 + for path in args.files: source = _read_source(path, console) if source is None: - return EXIT_IO + unreadable += 1 + continue try: program = check_source(source) except NanoCompileError as error: @@ -115,9 +154,15 @@ def command_check(args: Any, console: Console) -> int: f"effects {', '.join(program.effects)}, " f"ir {required_ir_version(program)}" ) - if failed: - console.warn(f"{failed} of {len(args.files)} file(s) failed") - return EXIT_DIAGNOSTICS + + if failed or unreadable: + console.warn( + f"{failed + unreadable} of {len(args.files)} file(s) failed" + + (f" ({unreadable} unreadable)" if unreadable else "") + ) + # A rejected program outranks an unreadable file: the diagnostic is the + # actionable result, and reporting I/O would bury it. + return EXIT_DIAGNOSTICS if failed else EXIT_IO return EXIT_OK @@ -146,6 +191,28 @@ def _emit_types(program, console: Console) -> None: console.say(f" warmup: {program.warmup} bar(s)") +def _write_or_print( + text: str, args: Any, console: Console, *, note: str +) -> int: + """Send `text` to `--output` if given, else to stdout. + + Shared by every emit mode. `--emit types` and `--emit plan` used to return + before reaching the output block, so `-o` was accepted, silently ignored, and + the command exited 0 having written nothing — the worst kind of failure, + because a script would believe it had a file. + """ + if args.output is None: + console.say(text) + return EXIT_OK + try: + args.output.write_text(text + "\n", encoding="utf-8") + except OSError as exc: + console.warn(f"error: cannot write {args.output}: {exc}") + return EXIT_IO + console.warn(f"{args.file} -> {args.output} ({note})") + return EXIT_OK + + def command_compile(args: Any, console: Console) -> int: """Validate a strategy and emit its execution plan.""" source = _read_source(args.file, console) @@ -154,35 +221,55 @@ def command_compile(args: Any, console: Console) -> int: try: if args.emit == "types": - _emit_types(check_source(source), console) - return EXIT_OK + program = check_source(source) + lines: List[str] = [] + _emit_types(program, Console(out=_Collector(lines), err=console.err)) + return _write_or_print( + "\n".join(lines), args, console, note="types" + ) if args.emit == "plan": - console.say(render(compile_module(source), "ascii")) - return EXIT_OK + module = _compile(args.file, source, console) + return _write_or_print( + render(module, "ascii"), args, console, note="plan" + ) document = compile_to_dict(source, ir_version=args.ir_version) + except _Rejected as rejected: + return rejected.code except NanoCompileError as error: return _report_compile_error(args.file, error, console) - except ValueError as error: - # IRValidationError / IRVersionError: the program is fine, but the shape - # that was asked for cannot hold it. + except IRVersionError as error: + # The program is fine; the version the caller asked for cannot hold it. + # That is a wrong argument, not a bad strategy. + console.warn(f"error: {error}") + return EXIT_USAGE + except IRValidationError as error: console.warn(f"{args.file}: error: {error}") return EXIT_DIAGNOSTICS - rendered = json.dumps(document, indent=2) - if args.output is None: - console.say(rendered) - return EXIT_OK - - try: - args.output.write_text(rendered + "\n", encoding="utf-8") - except OSError as exc: - console.warn(f"error: cannot write {args.output}: {exc}") - return EXIT_IO - console.warn( - f"{args.file} -> {args.output} " - f"(nanoIrVersion {document['nanoIrVersion']}, {len(document['nodes'])} nodes)" + return _write_or_print( + json.dumps(document, indent=2), + args, + console, + note=( + f"nanoIrVersion {document['nanoIrVersion']}, " + f"{len(document['nodes'])} nodes" + ), ) - return EXIT_OK + + +class _Collector: + """A minimal write sink, so `--emit types` can be captured for `-o`.""" + + def __init__(self, lines: List[str]) -> None: + self._lines = lines + + def write(self, text: str) -> int: + if text != "\n": + self._lines.append(text.rstrip("\n")) + return len(text) + + def flush(self) -> None: + return None # --------------------------------------------------------------------------- @@ -236,57 +323,68 @@ def _print_text_report( def command_replay(args: Any, console: Console) -> int: """Run a strategy against recorded data and report what it proposed.""" + # `--date` is parsed before anything expensive: a typo should not cost a whole + # compile, and a malformed date is a usage error rather than an I/O one. + try: + on_date = parse_date(args.date) if args.date else None + except FeedError as exc: + console.warn(f"error: {exc}") + return EXIT_USAGE + source = _read_source(args.file, console) if source is None: return EXIT_IO try: - module = compile_module(source) - except NanoCompileError as error: - return _report_compile_error(args.file, error, console) + module = _compile(args.file, source, console) + except _Rejected as rejected: + return rejected.code try: - on_date = parse_date(args.date) if args.date else None loaded = load_frame(args.data, on_date=on_date) except FeedError as exc: console.warn(f"error: {exc}") return EXIT_IO if not loaded.frame.timestamps: + # The file read fine; it just holds nothing for this date. That is a + # result, not a read failure. console.warn( "error: no rows to replay" + (f" for {args.date}" if args.date else "") + f" ({loaded.rows_read} row(s) read, " f"{loaded.rows_filtered} filtered out)" ) - return EXIT_IO + return EXIT_DIAGNOSTICS missing = _missing_signals(module, loaded.signal_names) if missing: + # A program/data mismatch: both inputs were readable and one does not fit + # the other. console.warn( f"error: {args.data} does not supply {', '.join(missing)} " f"(it has: {', '.join(loaded.signal_names)})" ) - return EXIT_IO + return EXIT_DIAGNOSTICS try: result = run_module(module, loaded.frame) - except Exception as exc: # noqa: BLE001 - reported as a diagnostic, not a crash + if args.verify: + # Same module, same frame, twice. A divergence means something in the + # chain is not a pure function of its inputs, which invalidates every + # number the run produced -- so it fails rather than warns. Inside the + # same guard as the first run: a fault on the verify pass is a fault. + again = run_module(module, loaded.frame) + if again.to_dict() != result.to_dict(): + console.warn( + "error: replay is not deterministic — two identical runs " + "produced different results" + ) + return EXIT_DIAGNOSTICS + except (RuntimeError_, IRValidationError) as exc: console.warn(f"error: replay failed: {exc}") return EXIT_DIAGNOSTICS - if args.verify: - # Same module, same frame, twice. A divergence means something in the - # chain is not a pure function of its inputs, which invalidates every - # number the run produced -- so it fails rather than warns. - again = run_module(module, loaded.frame) - if again.to_dict() != result.to_dict(): - console.warn( - "error: replay is not deterministic — two identical runs produced " - "different results" - ) - return EXIT_DIAGNOSTICS - if args.report == "json": console.say( json.dumps( @@ -321,21 +419,12 @@ def command_visualize(args: Any, console: Console) -> int: if source is None: return EXIT_IO try: - module = compile_module(source) - except NanoCompileError as error: - return _report_compile_error(args.file, error, console) - - rendered = render(module, args.format) - if args.output is None: - console.say(rendered) - return EXIT_OK - try: - args.output.write_text(rendered + "\n", encoding="utf-8") - except OSError as exc: - console.warn(f"error: cannot write {args.output}: {exc}") - return EXIT_IO - console.warn(f"{args.file} -> {args.output} ({args.format})") - return EXIT_OK + module = _compile(args.file, source, console) + except _Rejected as rejected: + return rejected.code + return _write_or_print( + render(module, args.format), args, console, note=args.format + ) # --------------------------------------------------------------------------- @@ -345,14 +434,16 @@ def command_visualize(args: Any, console: Console) -> int: def command_indicators(args: Any, console: Console) -> int: """List the indicators a strategy may compute, or describe one.""" - if args.name: + # `is not None`, not truthiness: `nano indicators ""` asked about an indicator + # and should be told there isn't one, not silently handed the whole list. + if args.name is not None: spec = INDICATORS.get(args.name) if spec is None: console.warn( f"error: unknown indicator {args.name!r} " "(try `nano indicators` for the full list)" ) - return EXIT_DIAGNOSTICS + return EXIT_USAGE console.say(spec.signature_text()) console.say(f" {spec.doc}") if spec.period_indices: @@ -383,10 +474,10 @@ def command_version(args: Any, console: Console) -> int: __all__ = [ "Console", "EXIT_DIAGNOSTICS", + "EXIT_INTERRUPTED", "EXIT_IO", "EXIT_OK", "EXIT_USAGE", - "FORMATS", "command_check", "command_compile", "command_indicators", diff --git a/nano/cli/render.py b/nano/cli/render.py index c5726c7..0e61bef 100644 --- a/nano/cli/render.py +++ b/nano/cli/render.py @@ -24,7 +24,7 @@ import json from typing import Dict, List, Sequence, Set -from ..ir.module import IRNode, NanoModule +from ..ir.module import NAMED_OPS, IRNode, NanoModule FORMATS = ("ascii", "mermaid", "dot", "json") @@ -58,7 +58,6 @@ "logic.not": "not", } -_NAMED_OPS = ("input.ref", "param.ref", "feed.signal", "let", "agent") _EFFECT_OPS = ("intent.emit", "llmre.escalate") @@ -67,7 +66,7 @@ def node_label(node: IRNode) -> str: base = _OP_LABELS.get(node.op, node.op) attrs = node.attrs - if node.op in _NAMED_OPS: + if node.op in NAMED_OPS: role = attrs.get("role") return f"{base} {attrs.get('name', '?')}" + (f" [{role}]" if role else "") if node.op == "schedule": diff --git a/nano/compiler/legacy.py b/nano/compiler/legacy.py index 0d60407..f72afe9 100644 --- a/nano/compiler/legacy.py +++ b/nano/compiler/legacy.py @@ -26,6 +26,7 @@ from dataclasses import dataclass from typing import List, Optional, Tuple +from ..ir.schema import CONDITION_OPERATORS from ..types.checker import ResolvedFeed, TypedProgram from .ast import ( ActionAst, @@ -38,7 +39,9 @@ NumberLit, ) -_COMPARISON_OPS = frozenset({"<", "<=", ">", ">=", "==", "!="}) +# One source for the operator vocabulary: the IR schema, which is what a +# `Condition` node may actually carry. +_COMPARISON_OPS = CONDITION_OPERATORS @dataclass(frozen=True) diff --git a/nano/compiler/parser.py b/nano/compiler/parser.py index 527e973..31d8214 100644 --- a/nano/compiler/parser.py +++ b/nano/compiler/parser.py @@ -90,6 +90,7 @@ StringLit, Unary, ) +from ..ir.schema import CONDITION_OPERATORS from .errors import NanoSyntaxError from .lexer import decode_string, tokenize from .tokens import Token @@ -100,7 +101,9 @@ _NULLARY_ACTIONS = {"execute": "EXECUTE", "pause": "PAUSE", "observe": "OBSERVE"} _ALL_ACTIONS = {**_ASSET_ACTIONS, **_NULLARY_ACTIONS} -_COMPARISON_OPS = frozenset({"<", "<=", ">", ">=", "==", "!="}) +# One source for the operator vocabulary: the IR schema, which is what a +# `Condition` node may actually carry. +_COMPARISON_OPS = CONDITION_OPERATORS _ADDITIVE_OPS = frozenset({"+", "-"}) _MULTIPLICATIVE_OPS = frozenset({"*", "/", "%"}) diff --git a/nano/ir/__init__.py b/nano/ir/__init__.py index ccd6729..6cdfb57 100644 --- a/nano/ir/__init__.py +++ b/nano/ir/__init__.py @@ -22,7 +22,6 @@ NanoModule, OpSpec, ParamDecl, - canonical_effects, ) from .nodes import AgentNode, ConditionNode, IntentNode, ScheduleNode from .schema import ( @@ -61,7 +60,6 @@ "ScheduleNode", "StrategyGraph", "TierViolation", - "canonical_effects", "load", "load_module", ] diff --git a/nano/ir/module.py b/nano/ir/module.py index 2f62741..cd86ca0 100644 --- a/nano/ir/module.py +++ b/nano/ir/module.py @@ -41,7 +41,6 @@ from .schema import ( AGENT_ROLES, - EFFECT_ORDER, INTENT_ACTIONS, IRValidationError, KNOWN_EFFECTS, @@ -318,6 +317,13 @@ def from_dict(data: Mapping[str, Any]) -> "NanoModule": unknown = set(effects_raw) - KNOWN_EFFECTS if unknown: raise IRValidationError(f"Unknown effects declared: {sorted(unknown)}") + if len(set(effects_raw)) != len(effects_raw): + # A manifest is a set of granted capabilities. Allowing a repeat would + # let two byte-different documents grant exactly the same thing, which + # makes `moduleHash` depend on manifest spelling rather than meaning. + raise IRValidationError( + f"Duplicate effects declared: {sorted(effects_raw)}" + ) effects = tuple(effects_raw) determinism = data.get("determinism", dict(DETERMINISM_CONTRACT)) @@ -412,6 +418,22 @@ def from_dict(data: Mapping[str, Any]) -> "NanoModule": provenance=dict(provenance), ) + def validate(self) -> "NanoModule": + """Re-run load-time validation, returning the validated module. + + ``from_dict`` is the trust boundary, but ``NanoModule`` is a frozen + dataclass, so in-process code can construct one directly and skip it — + and a module built that way could carry an `intent.emit` node its manifest + never granted. That is not a hypothetical: an audit built exactly such a + module and ran it. + + So the boundary is enforced where it matters rather than merely + documented: the VM calls this before executing. Cost is one pass over the + nodes, against an evaluation that is nodes × bars — the check disappears + into the noise of the work it protects. + """ + return NanoModule.from_dict(self.to_dict(include_hash=False)) + # -- serialise --------------------------------------------------------- def to_dict(self, *, include_hash: bool = True) -> dict: @@ -464,9 +486,10 @@ def content_hash(self) -> str: # attribute validation # --------------------------------------------------------------------------- -_NAMED_OPS = frozenset( - {"input.ref", "param.ref", "feed.signal", "let", "agent"} -) +# Opcodes whose behaviour is keyed by an `attrs["name"]`. Shared with the CLI +# renderer, which labels them the same way -- two copies of this set would drift +# the moment a named opcode is added. +NAMED_OPS = frozenset({"input.ref", "param.ref", "feed.signal", "let", "agent"}) def _validate_attrs(node: IRNode) -> None: @@ -481,7 +504,7 @@ def _validate_attrs(node: IRNode) -> None: _require_text(attrs, "interval", f"Node {node.id!r} (schedule)") return - if node.op in _NAMED_OPS: + if node.op in NAMED_OPS: _require_text(attrs, "name", f"Node {node.id!r} ({node.op})") if node.op == "agent": role = attrs.get("role") @@ -587,8 +610,3 @@ def _validate_attrs(node: IRNode) -> None: ) return - -def canonical_effects(effects: Sequence[str]) -> Tuple[str, ...]: - """Order an effect manifest canonically, so two compiles are byte-comparable.""" - present = set(effects) - return tuple(effect for effect in EFFECT_ORDER if effect in present) diff --git a/nano/runtime/vm.py b/nano/runtime/vm.py index 73e7f0e..d40dc52 100644 --- a/nano/runtime/vm.py +++ b/nano/runtime/vm.py @@ -20,10 +20,15 @@ Purity is the contract the reference interpreter has always held: identical module plus identical frame gives an identical result, bit for bit. No ambient clock, no -ambient randomness, no I/O. Reasoning calls are the one stochastic thing a module -can contain, and they do not break that — the provider is injected, and -``nano/agents/`` records results so a replay feeds back recorded data rather than -calling a model again. +ambient randomness, no I/O. + +Reasoning calls are the one stochastic thing a module can contain, and the VM +contains rather than solves that. It never constructs a provider — one is injected +or `infer` yields no value at all — so **the VM is exactly as deterministic as the +provider it was handed.** A provider that replays a recorded transcript makes a run +bit-reproducible; one that calls a live model does not, and no amount of care here +would change that. Recording transcripts is the host's job, and this package ships +no recorder: see ``ReasoningProvider`` below for the interface a host implements. """ from __future__ import annotations @@ -529,13 +534,22 @@ def run_module( frame: MarketFrame, *, provider: Optional[ReasoningProvider] = None, + validate: bool = True, ) -> ModuleResult: """Execute `module` over `frame`; return intents, escalations, and the log. - Pure with no provider, and pure *with* one whenever the provider is — which is - what ``nano.agents.RecordedProvider`` guarantees for replay. + The module is re-validated first. ``NanoModule`` is a frozen dataclass, so + in-process code can build one directly and bypass ``from_dict`` — and such a + module could carry an `intent.emit` node its manifest never granted. "The + runtime cannot execute invalid IR" has to be enforced here to be true, not + just asserted in a docstring. The check is one pass over the nodes against an + evaluation that is nodes × bars. + + Pass ``validate=False`` only in a loop that already validated the same module + (see ``run_frames``), never to accept a module you did not build. """ - return _Machine(module=module, frame=frame, provider=provider).run() + checked = module.validate() if validate else module + return _Machine(module=checked, frame=frame, provider=provider).run() def run_frames( @@ -544,5 +558,13 @@ def run_frames( *, provider: Optional[ReasoningProvider] = None, ) -> Tuple[ModuleResult, ...]: - """Execute `module` over several frames in order.""" - return tuple(run_module(module, frame, provider=provider) for frame in frames) + """Execute `module` over several frames in order. + + Validates once rather than once per frame: the module does not change between + frames, so re-checking it N times would only pay the cost N times. + """ + checked = module.validate() + return tuple( + run_module(checked, frame, provider=provider, validate=False) + for frame in frames + ) diff --git a/nano/types/checker.py b/nano/types/checker.py index b123cac..c4601a3 100644 --- a/nano/types/checker.py +++ b/nano/types/checker.py @@ -55,6 +55,7 @@ from ..indicators.registry import IndicatorSpec, lookup as lookup_indicator from ..ir.schema import ( AGENT_ROLES, + CONDITION_OPERATORS, EFFECT_ORDER, INTEGER_RISK_LIMITS, INTENT_ACTIONS, @@ -91,10 +92,13 @@ unify_comparison, unify_numeric, ) -from .lookahead import fold_int, resolve_offset, resolve_period +from .lookahead import fold_int, is_plain_int, resolve_offset, resolve_period _ARITHMETIC_OPS = frozenset({"+", "-", "*", "/", "%"}) -_COMPARISON_OPS = frozenset({"<", "<=", ">", ">=", "==", "!="}) +# The comparison set lives in the IR schema: it is the operator vocabulary a +# `Condition` node may carry, and a checker that accepted a seventh operator the +# IR could not represent would only fail later, further from the source. +_COMPARISON_OPS = CONDITION_OPERATORS _LOGICAL_OPS = frozenset({"and", "or"}) # `confidence` reads the confidence attached to the decision being evaluated: @@ -170,11 +174,6 @@ def feed_signals(self) -> Tuple[str, ...]: # --------------------------------------------------------------------------- -def _is_plain_int(value: object) -> bool: - """True for a real integer. `bool` is an int in Python and is not one here.""" - return isinstance(value, int) and not isinstance(value, bool) - - def _literal_type(value: object) -> Optional[Type]: if isinstance(value, bool): return BOOL @@ -453,7 +452,7 @@ def _check_risk(self) -> None: seen.add(limit.name) unit, low, high = spec - if limit.name in INTEGER_RISK_LIMITS and not _is_plain_int(limit.value): + if limit.name in INTEGER_RISK_LIMITS and not is_plain_int(limit.value): raise self._fail( f"Risk limit {limit.name!r} is measured in {unit} and must " f"be a whole number, got {limit.value}", @@ -571,7 +570,7 @@ def _visit(self, expr: Expr) -> Tuple[Type, int]: def _visit_uncached(self, expr: Expr) -> Tuple[Type, int]: if isinstance(expr, NumberLit): - return (INT if _is_plain_int(expr.value) else FLOAT), 0 + return (INT if is_plain_int(expr.value) else FLOAT), 0 if isinstance(expr, StringLit): return STRING, 0 if isinstance(expr, BoolLit): diff --git a/nano/types/lookahead.py b/nano/types/lookahead.py index 6016f04..360f193 100644 --- a/nano/types/lookahead.py +++ b/nano/types/lookahead.py @@ -37,8 +37,14 @@ _INTEGER_OPS = frozenset({"+", "-", "*", "%"}) -def _is_plain_int(value: object) -> bool: - """True for a real integer. `bool` is an int in Python and is not one here.""" +def is_plain_int(value: object) -> bool: + """True for a real integer. + + `bool` is a subclass of `int` in Python, so a bare isinstance check would let + `True` pass as a period or an offset. Public because the checker and the IR + loader both need exactly this test, and two copies of it would be two places + to forget the bool case. + """ return isinstance(value, int) and not isinstance(value, bool) @@ -50,14 +56,14 @@ def fold_int(expr: Expr, scope: Scope) -> Optional[int]: signal, an input, a let-binding, a float — does not fold, by design. """ if isinstance(expr, NumberLit): - return expr.value if _is_plain_int(expr.value) else None + return expr.value if is_plain_int(expr.value) else None if isinstance(expr, Name): symbol = scope.get(expr.name) if ( symbol is not None and symbol.is_constant - and _is_plain_int(symbol.const_value) + and is_plain_int(symbol.const_value) ): return int(symbol.const_value) return None diff --git a/tests/test_cli.py b/tests/test_cli.py index 868121f..b9f5c2a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,7 +14,13 @@ import pytest -from nano.cli.commands import EXIT_DIAGNOSTICS, EXIT_IO, EXIT_OK, Console +from nano.cli.commands import ( + EXIT_DIAGNOSTICS, + EXIT_IO, + EXIT_OK, + EXIT_USAGE, + Console, +) from nano.cli.main import build_parser, main from nano.cli.render import render from nano.compiler import compile_module @@ -242,8 +248,10 @@ def test_compile_can_be_forced_to_the_newer_version(legacy, capsys): def test_forcing_baseline_on_a_v1_strategy_explains_the_refusal(strategy, capsys): + # Usage, not a diagnostic: the strategy is fine and the requested version + # cannot hold it, which is a wrong argument rather than a bad program. code, _, err = _run(["compile", str(strategy), "--ir-version", "0.1.0"], capsys) - assert code == EXIT_DIAGNOSTICS + assert code == EXIT_USAGE assert "cannot represent" in err @@ -291,17 +299,20 @@ def test_replay_json_report_carries_hashes_and_the_audit_log(strategy, bars, cap def test_replay_names_the_signal_the_data_lacks(strategy, tmp_path, capsys): path = tmp_path / "wrong.csv" path.write_text("timestamp,volume\n0,10\n60,20\n", encoding="utf-8") + # Both inputs read fine; one does not fit the other. That is a diagnostic + # about the pair, not a failure to read either. code, _, err = _run(["replay", str(strategy), "--data", str(path)], capsys) - assert code == EXIT_IO + assert code == EXIT_DIAGNOSTICS assert "does not supply close" in err assert "it has: volume" in err def test_replay_reports_an_empty_date_selection(strategy, bars, capsys): + # The file was read successfully; it simply holds nothing for that date. code, _, err = _run( ["replay", str(strategy), "--data", str(bars), "--date", "2020-01-01"], capsys ) - assert code == EXIT_IO + assert code == EXIT_DIAGNOSTICS assert "no rows to replay for 2020-01-01" in err @@ -361,10 +372,19 @@ def test_indicators_describes_one_and_flags_constant_periods(capsys): assert "compile-time constants" in out -def test_unknown_indicator_exits_nonzero(capsys): +def test_unknown_indicator_is_a_usage_error(capsys): code, _, err = _run(["indicators", "NOPE"], capsys) - assert code == EXIT_DIAGNOSTICS + assert code == EXIT_USAGE + assert "unknown indicator" in err + + +def test_empty_indicator_name_does_not_silently_list_everything(capsys): + # `nano indicators ""` asked about an indicator. Truthiness would have handed + # back the whole list as though nothing had been asked. + code, out, err = _run(["indicators", ""], capsys) + assert code == EXIT_USAGE assert "unknown indicator" in err + assert out == "" def test_version_reports_both_ir_versions(capsys): diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..ef5a709 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,121 @@ +"""Documentation drift guards. + +Prose goes stale quietly; code examples go stale loudly, but only if something +runs them. A hardening audit found the README's headline strategy did not compile +(`observe market` — the grammar requires `observe()`) and that BUILD_ORDER's exit +criterion used `buy()`, which needs an asset. Both had been wrong through several +releases because nothing checked. + +So the docs are part of the test suite now. Every fenced `nano` block in the +repository has to compile, and the advertised test count has to stay in sight of +reality. + +A block opts out with a `// doc: illustrative` first line — for grammar sketches +and deliberately-rejected examples. The escape hatch is explicit and greppable, +which is the point: an example that cannot compile should say so rather than +quietly being one nobody checks. +""" + +import re +from pathlib import Path + +import pytest + +from nano.compiler import check_source +from nano.compiler.errors import NanoCompileError + +ROOT = Path(__file__).resolve().parent.parent + +# Files carrying `.nano` examples a reader is expected to be able to run. +DOC_FILES = sorted( + { + *ROOT.glob("*.md"), + *ROOT.glob("docs/**/*.md"), + *ROOT.glob("nano/library/README.md"), + *ROOT.glob("examples/README.md"), + } +) + +_FENCE = re.compile(r"^```nano[ \t]*$(.*?)^```[ \t]*$", re.MULTILINE | re.DOTALL) + +# Marks a block as prose rather than a runnable program. +OPT_OUT = "// doc: illustrative" + + +def _blocks(): + """Every runnable fenced `nano` block, as (relative path, start line, source).""" + found = [] + for path in DOC_FILES: + text = path.read_text(encoding="utf-8") + for match in _FENCE.finditer(text): + source = match.group(1) + if source.lstrip().startswith(OPT_OUT): + continue + line = text.count("\n", 0, match.start()) + 2 + found.append((path.relative_to(ROOT).as_posix(), line, source)) + return found + + +BLOCKS = _blocks() + + +def _rough_test_total() -> int: + """Count `def test_` across the suite — enough to catch a stale badge.""" + return sum( + len(re.findall(r"^def test_", path.read_text(encoding="utf-8"), re.MULTILINE)) + for path in (ROOT / "tests").glob("test_*.py") + ) + + +def test_the_docs_actually_contain_examples(): + """A guard on the guard: a broken fence regex would silently pass everything. + + Three is the current count, and the floor is deliberately set just under it. + This assertion is not about having many examples — it is about noticing if the + regex ever stops matching, which would turn every check below into a no-op that + reports success. + """ + assert len(BLOCKS) >= 3, f"only found {len(BLOCKS)} nano blocks — check the regex" + + +@pytest.mark.parametrize( + "location, source", + [(f"{path}:{line}", source) for path, line, source in BLOCKS], + ids=[f"{path}:{line}" for path, line, _ in BLOCKS], +) +def test_every_documented_example_compiles(location: str, source: str): + try: + check_source(source) + except NanoCompileError as error: + pytest.fail( + f"{location}: documented example does not compile — " + f"{error.line}:{error.column}: {error.message}\n" + f"Mark it `{OPT_OUT}` if it is prose rather than a program." + ) + + +def test_the_advertised_test_count_is_not_stale(): + """The README badge and prose quote a test count, and numbers rot silently. + + Rather than pin an exact figure this test would itself have to chase, assert + that whatever the README claims stays within sight of the real total — enough + to catch "173" surviving into a 300-test suite. + """ + readme = (ROOT / "README.md").read_text(encoding="utf-8") + claimed = {int(n) for n in re.findall(r"tests-(\d+)%20passing", readme)} + claimed |= {int(n) for n in re.findall(r"\*\*(\d+) tests passing\*\*", readme)} + claimed |= {int(n) for n in re.findall(r"(\d+) passing tests", readme)} + if not claimed: + pytest.skip("README no longer advertises a test count") + + # The bound is directional, not a percentage. The README quotes the figure + # `pytest` prints, which parametrisation pushes above the number of `def test_` + # functions — so the real total is always at least the function count, and a + # claim below it is stale. The upper bound only catches a wild typo. + functions = _rough_test_total() + for number in sorted(claimed): + assert functions <= number <= functions * 4, ( + f"README claims {number} tests; the suite defines {functions} test " + "functions, so the collected total is at least that. Update the badge " + "and the prose together." + ) diff --git a/tests/test_module.py b/tests/test_module.py index 41bbd87..9251ae9 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -327,3 +327,71 @@ def test_load_module_lifts_baseline_so_runtimes_need_one_path(): def test_an_unknown_version_is_rejected_rather_than_guessed(): with pytest.raises(IRValidationError, match="unsupported"): load({"type": "Strategy", "nanoIrVersion": "9.9.9", "nodes": []}) + + +# -- the boundary holds against direct construction --------------------------- + + +def _handbuilt_module(**overrides): + """A module assembled by hand, skipping ``from_dict`` entirely.""" + from nano.ir.module import IRNode + + fields = { + "name": "Sneak", + "tier": "nano", + "effects": ("log.append",), + "nodes": ( + IRNode(id="n1", op="schedule", attrs={"interval": "1m"}), + IRNode(id="n2", op="const", attrs={"value": True}), + IRNode(id="n3", op="intent.emit", attrs={"action": "BUY"}), + IRNode(id="n4", op="block", inputs=("n3",)), + IRNode(id="n5", op="rule", inputs=("n1", "n2", "n4")), + ), + "entries": ("n5",), + } + fields.update(overrides) + return NanoModule(**fields) + + +def test_validate_catches_a_module_that_never_went_through_the_loader(): + """``NanoModule`` is a frozen dataclass, so in-process code can build one and + skip ``from_dict``. An audit did exactly that and ran an ungranted + `intent.emit`. ``validate()`` is what makes the boundary real.""" + with pytest.raises(ManifestViolation, match=r"intent\.emit"): + _handbuilt_module().validate() + + +def test_the_vm_refuses_a_hand_built_module_with_an_ungranted_effect(): + from nano.runtime.interpreter import MarketFrame + from nano.runtime.vm import run_module + + frame = MarketFrame(timestamps=(0,), signals={}) + with pytest.raises(ManifestViolation, match=r"intent\.emit"): + run_module(_handbuilt_module(), frame) + + +def test_a_correctly_declared_hand_built_module_still_runs(): + # The check rejects an ungranted capability, not hand construction itself -- + # `to_module()` builds modules directly and must keep working. + from nano.runtime.interpreter import MarketFrame + from nano.runtime.vm import run_module + + module = _handbuilt_module(effects=("intent.emit", "log.append")) + result = run_module(module, MarketFrame(timestamps=(0,), signals={})) + assert [i.action for i in result.intents] == ["BUY"] + + +def test_run_frames_validates_once_and_still_rejects(): + from nano.runtime.interpreter import MarketFrame + from nano.runtime.vm import run_frames + + frames = [MarketFrame(timestamps=(0,), signals={})] + with pytest.raises(ManifestViolation): + run_frames(_handbuilt_module(), frames) + + +def test_duplicate_effects_are_rejected(): + # Two byte-different documents granting the same capability would make + # moduleHash depend on manifest spelling rather than meaning. + with pytest.raises(IRValidationError, match="Duplicate effects"): + NanoModule.from_dict(document(effects=["log.append", "log.append"]))