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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions contract/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,68 @@ incl. Pyfa's mass / capacity / volume / radius attributes), `effects` [{id, name
(Pyfa Traits tab HTML), `required_skills` {skill id: level} (cases `type_*`; MKT-003, ENG-SHIP-006, CHR-008; a
case's `_fields` param lists the fields it scores).

## `compute` (unified entry, `exfa/compute@1`)

One input object produces one output object; the engine stays workspace/group-agnostic and only sees fully
resolved fits (no host-side `fit_id` / `character_id` references). Available as the `compute` RPC method
(`params` = the request envelope), the `exfa compute [FILE]` CLI (file or stdin → one JSON line on stdout,
exit 2 on an error envelope), and `exfa_core::compute_json` / `exfa_core::compute::compute` (WASM gets the RPC
method for free).

```jsonc
// request
{"format": "exfa/compute@1", "operation": "calc", "fit": { /* FitSpec */ }}
{"format": "exfa/compute@1", "operation": "batch", "batch": { /* BatchSpec */ }}
// response
{"format": "exfa/compute-result@1", "operation": "calc", "result": { /* FitStats */ }}
{"format": "exfa/compute-result@1", "operation": "batch", "result": { /* BatchResponse */ }}
{"format": "exfa/compute-result@1", "operation": "calc", "error": {"code": "...", "message": "...", "path": "..."}}
```

- A missing or non-`"exfa/compute@1"` `format` → `UNSUPPORTED_FORMAT`; a missing or non-`"calc"`/`"batch"`
`operation` → `BAD_REQUEST`; inner calc/batch failures keep their `code`/`message`/`path` inside the error
envelope. A top-level JSON parse failure still answers with the error envelope (`BAD_JSON`; `operation`
echoed when recoverable from the raw text, else `"calc"`). `compute_json` never panics.
- `batch` is exactly the docs/23 BatchRequest (`batch_version`, exactly one of `fits`/`variants`/`product`/
`sweep`, `fields`, `deltas`, `delta_ref`, `filter`, `sort_by`, `top_n`, `max_combinations`, price layers);
expansion, per-item errors and the `fits[]` item `id`/`label` echo behave as before.

**FitSpec = FitRequest + stable ids + shorthand normalization.**

- `modules[]`, `drones[]` and `fighters[]` entries take an optional string `id` (a stable caller-chosen id;
ignored everywhere except `select`, below).
- Shorthand defaults (applied by the `compute` entry point only — `calc`/`batch`/`graph` keep the existing
FitRequest defaults; explicit values always win, so `default_level: 0` stays 0):
- `character.skills.default_level` omitted → `5` (an omitted `character` is the same);
- `modules[].state` omitted → `"active"` when the type can be activated (has an active or target effect and
`activationBlocked` ≤ 0 — the `type` RPC `allowed_states` rule), else `"online"`; an unknown type is left
alone so the usual error reports it;
- `drones[].active` omitted → `quantity` (every drone active); `fighters[].active` already defaults true;
- everything else uses the existing FitRequest defaults at build time.
- Normalization recurses into every nested FitRequest (`projected[kind=fit].fit`, `fleet.booster_fits[]`,
`scenarios[].target.fit`). Under `operation=calc` each defaulted category is reported once as an
`adjustments[]` entry `{"code": "DEFAULTED", "path": "/character/skills/default_level" | "/modules/*/state" |
"/drones/*/active", "from": null, "to": <value | list of applied values>, "message": "compute shorthand
default applied"}` (top-level fit only). Under `operation=batch` the `base` and each `fits[].fit` are
normalized silently *before* expansion, so patches act on a fully normalized base.

**`select` on `projected[kind=fit]`** chooses which of the source fit's items project onto this fit:

```jsonc
{"kind": "fit", "fit": { /* FitSpec */ }, "select": {"module_ids": ["rep-1"]}, "amount": 1, "distance_m": 8000}
```

- `select` omitted → the existing behaviour: every module at state ≥ active, every active drone (its `active`
count), every active fighter squadron.
- `select` present → an explicit whitelist per kind: a module projects only when its `id` is in `module_ids`,
a drone only in `drone_ids`, a fighter only in `fighter_ids` (the usual active-state rules still apply). A
kind whose list is absent contributes **nothing**; `{"module_ids": []}` projects no modules.
- The source fit is still computed completely first (all its modules, skills, implants, boosters and buffs
shape its attributes); `select` only filters which items' projection reaches the target. Every id in a
provided list that names no item of that kind in the source fit yields one `warnings[]` entry
(`projected fit select: no <kind> item with id '<id>'`).
- `select` on a `projected` entry whose `kind` is not `"fit"` is ignored (a `warnings[]` entry notes it).

## Changelog
- v1 (2026-10-03): initial contract.
- v1.1 (2026-10-03): `fleet.booster_fits` implemented (oracle-verified). `projected[kind=fit]` and charges on
Expand Down
12 changes: 11 additions & 1 deletion crates/exfa-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ const USAGE: &str = "exfa <command> [args] (dataset compiled in; --dataset PAT

Commands:
calc [FILE] FitRequest JSON (file or stdin) -> FitStats JSON
compute [FILE] exfa/compute@1 envelope (file or stdin) -> one exfa/compute-result@1 JSON line
batch JSONL FitRequests on stdin -> JSONL FitStats on stdout (a BatchRequest line -> one BatchResponse line)
batch --request FILE|- BatchRequest JSON (docs/23: fits / variants / product / sweep) -> BatchResponse JSON
optimize [FILE] OptimizeRequest JSON (docs/21) -> ranked fits
serve-stdio JSONL RPC: {\"id\":..,\"method\":\"calc|batch|prices_load|version|sde_override|optimize|graph|search|type|meta|eft_parse|eft_export|format_import|format_export|fits.backup|item.variations|item.compare|market.group|market.search|implant_sets.list|character.import_evemon|names.resolve|pyfa_data_load|pyfa_data_status\",\"params\":..}
serve-stdio JSONL RPC: {\"id\":..,\"method\":\"calc|batch|compute|prices_load|version|sde_override|optimize|graph|search|type|meta|eft_parse|eft_export|format_import|format_export|fits.backup|item.variations|item.compare|market.group|market.search|implant_sets.list|character.import_evemon|names.resolve|pyfa_data_load|pyfa_data_status\",\"params\":..}
eft [FILE] EFT text (file or stdin) -> FitRequest JSON (add --calc to compute, --skills N)
search QUERY [--limit N] [--kinds ship,module,..] search types by name (exact > prefix > substring)
type ID|NAME show type with base attributes
Expand Down Expand Up @@ -119,6 +120,15 @@ fn main() {
std::process::exit(2);
}
}
"compute" => {
let s = read_input(args.get(1));
let res = exfa_core::compute_json(&s);
writeln!(out, "{res}").or_pipe();
out.flush().or_pipe();
if serde_json::from_str::<serde_json::Value>(&res).ok().and_then(|v| v.get("error").cloned()).is_some() {
std::process::exit(2);
}
}
"batch" => {
if let Some(f) = take_flag(&mut args, "--request") {
let res = exfa_core::batch_json(&read_input(Some(&f)));
Expand Down
212 changes: 212 additions & 0 deletions crates/exfa-core/src/compute.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
//! Unified compute envelope `exfa/compute@1` -> `exfa/compute-result@1` (docs/27 §5.3): ONE input object,
//! ONE output object. `operation=calc` takes a `FitSpec` and returns FitStats; `operation=batch` takes a
//! `BatchSpec` (the existing BatchRequest) and returns the BatchResponse. The workspace/group semantics and
//! host-side references stay outside; the engine only sees fully resolved fits.
//!
//! FitSpec = FitRequest + stable equipment `id`s (modules/drones/fighters, used by
//! `projected[kind=fit].select`) + shorthand normalization, applied by this entry point only: omitted
//! `character.skills.default_level` = 5, omitted module `state` = `active` when the type can activate else
//! `online`, omitted drone `active` = `quantity` (fighters already default active). Explicit values win
//! (`default_level: 0` stays 0). Normalization recurses into every nested FitRequest (`projected[kind=fit].fit`,
//! `fleet.booster_fits[]`, `scenarios[].target.fit`); for `operation=calc` the top-level defaults are reported
//! as `DEFAULTED` adjustments, for `operation=batch` the same rules apply silently before expansion.
use crate::data::{self as d, a};
use crate::j::J;
use crate::jv;
use crate::request::{FitRequest, State};
use serde_json::{json, Value};
use std::ops::IndexMut;

pub const FORMAT: &str = "exfa/compute@1";
pub const RESULT_FORMAT: &str = "exfa/compute-result@1";

fn err_env(op: &str, code: &str, message: impl Into<String>, path: &str) -> Value {
json!({"format": RESULT_FORMAT, "operation": op, "error": {"code": code, "message": message.into(), "path": path}})
}

/// Best-effort `operation` for the error envelope of an unparseable request.
fn recover_op(s: &str) -> Option<String> {
let k = s.find("\"operation\"")? + "\"operation\"".len();
let rest = s[k..].trim_start().strip_prefix(':')?.trim_start().strip_prefix('"')?;
rest.find('"').map(|e| rest[..e].to_string())
}

/// The engine-side `state` shorthand default (Pyfa isValidState, same rule as `type` RPC `allowed_states`):
/// `active` when the type has an activatable effect (category 1|2) and `activationBlocked` <= 0, else `online`.
/// Unknown types stay `None` so the existing error path reports them.
fn state_default(type_id: u32) -> Option<State> {
let ty = d::type_index(type_id)?;
let can_active = d::type_effects(ty).iter().any(|&x| matches!(d::EFF_META[(x >> 1) as usize].cat, 1 | 2))
&& d::type_attr(ty, a::activationBlocked).unwrap_or(0.0) <= 0.0;
Some(if can_active { State::Active } else { State::Online })
}

/// Defaults applied to the top-level fit (calc reports them as `DEFAULTED` adjustments).
#[derive(Default)]
struct Notes {
default_level: bool,
module_states: Vec<State>,
drone_actives: Vec<u32>,
}

/// Normalize a FitSpec in place, recursing into every nested FitRequest. Notes are recorded for the
/// top-level fit only (the existing convention: `adjustments` reports the top-level request).
fn normalize(req: &mut FitRequest, top: bool, notes: &mut Notes) {
if req.character.skills.default_level.is_none() {
req.character.skills.default_level = Some(5);
if top {
notes.default_level = true;
}
}
for m in &mut req.modules {
if m.state.is_none() {
if let Some(st) = state_default(m.type_id) {
m.state = Some(st);
if top {
notes.module_states.push(st);
}
}
}
}
for dr in &mut req.drones {
if dr.active.is_none() {
dr.active = Some(dr.quantity);
if top {
notes.drone_actives.push(dr.quantity);
}
}
}
for bf in &mut req.fleet.booster_fits {
normalize(bf, false, notes);
}
for p in &mut req.projected {
if p.kind == "fit" {
if let Some(f) = p.fit.as_deref_mut() {
normalize(f, false, notes);
}
}
}
if let Some(Value::Array(list)) = req.scenarios.as_mut() {
for s in list.iter_mut() {
if let Some(v) = s.get_mut("target").and_then(|t| t.get_mut("fit")).filter(|v| v.is_object()) {
normalize_fit_json(v);
}
}
}
}

/// Normalize a FitSpec held as JSON (batch base / fits[].fit / scenario target.fit); on a shape error the
/// value is left as-is so the downstream path reports it.
fn normalize_fit_json(v: &mut Value) {
if let Ok(mut req) = serde_json::from_value::<FitRequest>(v.clone()) {
normalize(&mut req, false, &mut Notes::default());
if let Ok(nv) = serde_json::to_value(&req) {
*v = nv;
}
}
}

/// One `DEFAULTED` adjustment per defaulted category (wildcard paths cover every defaulted item; `to` lists
/// the applied values in request order).
fn defaulted_adjustments(n: &Notes) -> Vec<J> {
let mut v = Vec::new();
if n.default_level {
v.push(jv!({"code": "DEFAULTED", "path": "/character/skills/default_level", "from": J::Null, "to": 5u8, "message": "compute shorthand default applied"}));
}
if !n.module_states.is_empty() {
v.push(jv!({"code": "DEFAULTED", "path": "/modules/*/state", "from": J::Null, "to": n.module_states.clone(), "message": "compute shorthand default applied"}));
}
if !n.drone_actives.is_empty() {
v.push(jv!({"code": "DEFAULTED", "path": "/drones/*/active", "from": J::Null, "to": n.drone_actives.clone(), "message": "compute shorthand default applied"}));
}
v
}

/// Wrap an inner `{"error": {...}}` output (calc or batch) into the error envelope, keeping its code/message/path.
fn inner_error(op: &str, e: &Value) -> Value {
let mut e = e.clone();
if let Some(m) = e.as_object_mut() {
m.entry("path").or_insert(json!(""));
}
json!({"format": RESULT_FORMAT, "operation": op, "error": e})
}

fn calc_op(input: &Value) -> Value {
let fit_v = match input.get("fit") {
Some(v) if v.is_object() => v.clone(),
_ => return err_env("calc", "BAD_REQUEST", "fit must be a FitRequest object", "/fit"),
};
let mut req: FitRequest = match serde_json::from_value(fit_v) {
Ok(r) => r,
Err(e) => return err_env("calc", "BAD_REQUEST", e.to_string(), "/fit"),
};
let mut notes = Notes::default();
normalize(&mut req, true, &mut notes);
let mut out = crate::calc(&req);
if let J::O(o) = &out {
if let Some((_, e)) = o.iter().find(|(k, _)| k.as_ref() == "error") {
return inner_error("calc", &e.to_value_raw());
}
}
if let J::O(_) = &out {
let adj = out.index_mut("adjustments");
if !matches!(adj, J::A(_)) {
*adj = J::A(Vec::new());
}
if let J::A(a) = adj {
a.extend(defaulted_adjustments(&notes));
}
}
let result = if req.options.full_precision { out.to_value_raw() } else { serde_json::to_value(&out).unwrap_or(Value::Null) };
json!({"format": RESULT_FORMAT, "operation": "calc", "result": result})
}

fn batch_op(input: &Value) -> Value {
let mut b = match input.get("batch") {
Some(v) if v.is_object() => v.clone(),
_ => return err_env("batch", "BAD_REQUEST", "batch must be a BatchRequest object", "/batch"),
};
// FitSpec shorthand applies to the base and every explicit fit BEFORE expansion, so patches act on a
// fully normalized base and there is no shorthand-vs-normalized ambiguity downstream.
if let Some(v) = b.get_mut("base").filter(|v| v.is_object()) {
normalize_fit_json(v);
}
if let Some(fits) = b.get_mut("fits").and_then(Value::as_array_mut) {
for it in fits.iter_mut() {
if let Some(v) = it.get_mut("fit").filter(|v| v.is_object()) {
normalize_fit_json(v);
}
}
}
let r = crate::batch::run(&b);
match r.get("error") {
Some(e) => inner_error("batch", e),
None => json!({"format": RESULT_FORMAT, "operation": "batch", "result": r}),
}
}

/// `compute` params (`exfa/compute@1`) -> `exfa/compute-result@1`.
pub fn compute(input: &Value) -> Value {
if input.get("format").and_then(Value::as_str) != Some(FORMAT) {
let op = input.get("operation").and_then(Value::as_str).unwrap_or("calc");
return err_env(op, "UNSUPPORTED_FORMAT", "format must be \"exfa/compute@1\"", "/format");
}
let op = input.get("operation").and_then(Value::as_str).unwrap_or("");
match op {
"calc" => calc_op(input),
"batch" => batch_op(input),
_ => err_env(if op.is_empty() { "calc" } else { op }, "BAD_REQUEST", "operation must be \"calc\" or \"batch\"", "/operation"),
}
}

/// JSON string in, JSON string out; never panics and always answers with the result envelope.
pub fn compute_json(input: &str) -> String {
let v = match serde_json::from_str::<Value>(input) {
Ok(v) => compute(&v),
Err(e) => {
let op = recover_op(input).unwrap_or_else(|| "calc".into());
err_env(&op, "BAD_JSON", e.to_string(), "")
}
};
serde_json::to_string(&v).unwrap_or_default()
}
Loading
Loading