/* * logic.cpp — Antheos Protocol v9 LOGICAL body grammar (spec §6.2.1) * * Recursive-descent parser for the V1 LOGICAL grammar, its evaluator, and the * canonical emitter. * * ANTHEOS-LOGICAL-V1-PARSER-IMPL. §6.2.1 locked the grammar at v1.0.8 and no * runtime code honoured it: Scaleback handling (§9.2) relied on consumer-side * ad-hoc parsing of the LOGICAL body, so every consumer reinvented it and none * of them agreed by construction. * * SYNTAX ONLY, and that is a spec commitment rather than a shortcut: §6.2.1 * says atom semantics are consumer-defined, not grammar-defined. This parser * reports which atoms appear and how they combine; what `H` or `T` MEAN is the * consumer's, supplied to evaluate_logical as a callback. Scaleback reads them * as flag exclusions, a value-class consumer may read them as literals, and * neither reading belongs in here. * * Copyright (c) 2025-2026 Are Bjørby * SPDX-License-Identifier: MIT */ #include "antheos.hpp" namespace antheos::logic { namespace { /* §6.2.1 V1 atom set — eleven CP437 characters, drawn from the radix-flag * (§6.3) and unit-flag (§6.4) alphabets so a Scaleback expression parses with * no extra symbol table. `D` is shared between the two; which one it means is * the consumer's business. `F` is deliberately absent — there is no F-flag in * either set, and the spec calls that out because it is the character a reader * most expects to find. */ bool is_atom(char c) { switch (c) { case 'I': case 'O': case 'D': case 'H': case 'U': /* radix, §6.3 */ case 'B': case 'W': case 'Q': case 'M': case 'G': case 'T': /* unit, §6.4 */ return true; default: return false; } } /* One-pass recursive descent, shaped exactly like the EBNF so the two can be * read side by side: * * disjunction := conjunction ( "|" conjunction )* * conjunction := negation ( "&" negation )* * negation := "!" negation | primary * primary := atom | "(" disjunction ")" * * Precedence is the nesting depth — `!` binds tighter than `&`, which binds * tighter than `|` — and both binary operators are left-associative, which the * loops give for free by folding each new right operand into the accumulated * left one. * * There is no lexer. V1 admits no whitespace and every token is one character, * so a separate token stream would be a layer with nothing in it. */ class Parser { public: explicit Parser(std::string_view s) : s_(s) {} std::unique_ptr parse() { auto e = disjunction(); if (!e) return nullptr; if (pos_ != s_.size()) { /* trailing junk: `H)` , `H Q` */ fail("unexpected character at position " + std::to_string(pos_)); return nullptr; } return e; } const std::string& error() const { return error_; } private: std::string_view s_; size_t pos_ = 0; std::string error_; bool at_end() const { return pos_ >= s_.size(); } char peek() const { return at_end() ? '\0' : s_[pos_]; } void fail(std::string why) { if (error_.empty()) error_ = std::move(why); /* keep the FIRST cause */ } static std::unique_ptr make(LogicalExpr::Kind k) { auto n = std::make_unique(); n->kind = k; return n; } std::unique_ptr disjunction() { auto left = conjunction(); if (!left) return nullptr; while (peek() == '|') { pos_++; auto right = conjunction(); if (!right) return nullptr; auto node = make(LogicalExpr::Kind::Or); node->left = std::move(left); node->right = std::move(right); left = std::move(node); } return left; } std::unique_ptr conjunction() { auto left = negation(); if (!left) return nullptr; while (peek() == '&') { pos_++; auto right = negation(); if (!right) return nullptr; auto node = make(LogicalExpr::Kind::And); node->left = std::move(left); node->right = std::move(right); left = std::move(node); } return left; } std::unique_ptr negation() { if (peek() == '!') { pos_++; auto inner = negation(); /* right-recursive: `!!H` */ if (!inner) return nullptr; auto node = make(LogicalExpr::Kind::Not); node->left = std::move(inner); return node; } return primary(); } std::unique_ptr primary() { if (at_end()) { fail("expected an atom or `(`, found end of body"); return nullptr; } const char c = peek(); if (c == '(') { pos_++; auto inner = disjunction(); if (!inner) return nullptr; if (peek() != ')') { fail("unmatched `(`"); return nullptr; } pos_++; return inner; } if (is_atom(c)) { pos_++; auto node = make(LogicalExpr::Kind::Atom); node->atom = c; return node; } fail(std::string("`") + c + "` is not a V1 atom (I O D H U B W Q M G T) " "and not an operator (! & | parens)"); return nullptr; } }; /* Emission precedence, so the canonical form carries the parens it needs and no * others. Higher binds tighter. */ int prec(LogicalExpr::Kind k) { switch (k) { case LogicalExpr::Kind::Or: return 1; case LogicalExpr::Kind::And: return 2; case LogicalExpr::Kind::Not: return 3; case LogicalExpr::Kind::Atom: return 4; } return 0; } void emit_into(const LogicalExpr& e, std::string& out); /* Wrap a child when dropping its parens would change what the string parses * back to. `on_right` carries left-associativity: `A&(B&C)` needs its parens * because `A&B&C` reads as `(A&B)&C`, while `(A&B)&C` does not. */ void emit_child(const LogicalExpr& child, LogicalExpr::Kind parent, bool on_right, std::string& out) { const bool wrap = on_right ? prec(child.kind) <= prec(parent) : prec(child.kind) < prec(parent); if (wrap) out += '('; emit_into(child, out); if (wrap) out += ')'; } void emit_into(const LogicalExpr& e, std::string& out) { switch (e.kind) { case LogicalExpr::Kind::Atom: out += e.atom; return; case LogicalExpr::Kind::Not: out += '!'; if (e.left) emit_child(*e.left, LogicalExpr::Kind::Not, false, out); return; case LogicalExpr::Kind::And: case LogicalExpr::Kind::Or: if (e.left) emit_child(*e.left, e.kind, false, out); out += (e.kind == LogicalExpr::Kind::And) ? '&' : '|'; if (e.right) emit_child(*e.right, e.kind, true, out); return; } } } // namespace std::optional parse_logical(std::string_view body, std::string* error) { if (body.empty()) { /* §6.2.1 asks for a teaching error here specifically, and it is the one * rejection a caller is most likely to reach by accident: an absent * body and an empty one are the same bytes on the wire. */ if (error) *error = "a LOGICAL body may not be empty — it must carry an " "expression over the V1 atoms (I O D H U B W Q M G T)"; return std::nullopt; } Parser p(body); auto root = p.parse(); if (!root) { if (error) *error = p.error(); return std::nullopt; } return std::optional(std::move(*root)); } bool evaluate_logical(const LogicalExpr& expr, const AtomEvaluator& atom_eval) { switch (expr.kind) { case LogicalExpr::Kind::Atom: return atom_eval(expr.atom); case LogicalExpr::Kind::Not: return expr.left ? !evaluate_logical(*expr.left, atom_eval) : false; case LogicalExpr::Kind::And: /* Short-circuit, so a consumer's evaluator is not called for an * atom whose value cannot affect the answer. */ if (!expr.left || !evaluate_logical(*expr.left, atom_eval)) return false; return expr.right && evaluate_logical(*expr.right, atom_eval); case LogicalExpr::Kind::Or: if (expr.left && evaluate_logical(*expr.left, atom_eval)) return true; return expr.right && evaluate_logical(*expr.right, atom_eval); } return false; } std::string emit_logical(const LogicalExpr& expr) { std::string out; emit_into(expr, out); return out; } } // namespace antheos::logic