# Roadflow Model — agent reference

> **URL:** `/model/` (prototype, `noindex`)  
> **Human docs:** in-app **?** menu → Instructions (EN/RU). Same schema, dictionaries, formula rules, and interface/drawer map as this file.  
> **MCP:** `model_*` tools on `https://roadflow.io/mcp` (same endpoint as Plans/Artifacts). REST: `/v1/model-projects`. Docs: `/mcp.html`.

This file is the machine-oriented contract. Read it before creating or editing a model.

---

## 1. Purpose

Roadflow Model is a **parameter register** and **dependency graph** (finance / capacity / data-center unit economics). It is **not** Roadflow Plans (roadmap canvas) and **not** a spreadsheet blob.

A model stores:

| Entity | What it is |
|--------|------------|
| **Project** | One register (`mp-…` edit id). Title + calendar + `rev`. |
| **Stream** | Swimlane (table) / column (graph). |
| **Parameter** | Catalog row: identity, role, formula, SBC wiring, scope axes. **Not** the numbers. |
| **Observation** | One **fact**: value + evidence for one `(parameter, scope, set)`. |
| **Axis + members** | Slice dimensions (Workload, Site…). Period/Phase are generated. |
| **Phase** | Named window over generated periods. |
| **Parameter set** | Overlay on **values** (Base / Downside / Upside). **Not an axis.** |
| **Edge** | Graph link. Persist only SOURCE / FLOW / INTERFACE. |
| **Chain** | Ordered lineage path + optional destination KPI. |
| **Calendar** | Month/quarter/year range that generates Period members. |

In-memory UI state is `ModelState`. Persistence is **one MySQL row per record**, not one JSON document. Only UI prefs stay in `localStorage`.

**Hard rules**

- Sets apply to observation **values**. Base is required. Blank Downside/Upside inherit Base.
- Do **not** persist CALC outputs (unless the user set `valueOverride`).
- Do **not** persist auto **FORMULA** edges.
- Scope is JSON + canonical `scope_key`. `set_id` is a column, not an axis.

---

## 2. Persistence

| Store | Content |
|-------|---------|
| MySQL `model_*` | Project, streams, axes, members, phases, sets, parameters, observations, edges, chains + steps. Optimistic `rev` on mutable rows. |
| `GET /v1/model-projects/:id` | Hydrate all records |
| `PATCH /v1/model-projects/:id` | Dictionaries / title / calendar (`If-Match: rev`) |
| `PUT …/parameters/:parameterId` | Catalog only (not observations) |
| `PUT …/parameters` | Bulk catalog (array or `{ parameters, continueOnError? }`) |
| `PUT …/observations` | One fact, or bulk if body is an array / `{ observations }` |
| `PUT …/edges/:edgeId` | Manual SOURCE / FLOW / INTERFACE only |
| `roadflow-model-project-id` | Bound edit id (`mp-…`) — pointer only |
| `roadflow-model-ui-v1` | UI prefs (local only) |
| `roadmap-locale` | `en` \| `ru` |

Bind via `?id=` (edit or view). **New** / **Load demo** unbind so they do not overwrite an existing register. With the API up, dirty **records** flush; `roadflow-model-v1` is cleared. That key is only a `--skip-db` fallback.

---

## 3. MCP tools (`model_*`)

Same Streamable HTTP endpoint as Plans (`/mcp`). Bind with `model_new` or `model_open`, then patch **one record** (or a capped bulk). Optional `id` on each tool overrides the session binding. `rev` is If-Match.

| Tool | Grain |
|------|--------|
| `model_new` / `model_open` / `model_status` / `model_discard` | Project session |
| `model_get` | Hydrate (`summary` \| `all` \| `dictionaries` \| `parameters` \| `observations` \| `edges`) |
| `model_patch_project` | Title, calendar, streams, axes, phases, sets, chains |
| `model_list_parameters` / `model_get_parameter` / `model_upsert_parameter` / `model_delete_parameter` | Catalog |
| `model_upsert_parameters` | Bulk catalog (cap 400). `{ parameters, continueOnError? }` → `{ ok, results: [{ parameterId, rev \| error }] }` |
| `model_list_observations` / `model_upsert_observation` / `model_delete_observation` | One fact |
| `model_upsert_observations` | Bulk facts (cap 400). Same continueOnError / per-row errors. Still rejects CALC outputs unless `valueOverride`. |
| `model_list_edges` / `model_upsert_edge` / `model_delete_edge` | Manual SOURCE / FLOW / INTERFACE |
| `model_evaluate` | Read-only `computeModel`. No MySQL writes. View ids allowed. Default `periodId` = calendar **start** (not today). `periodId` XOR `rangeStart`/`rangeEnd`. Cell cap **2000** → `{ ok: false, truncated: true }` (empty `cells`). REST: `POST /v1/model-projects/:id/evaluate`. |
| `model_list_dictionaries` | Streams, axes, phases, sets, chains, calendar |

**New model:** empty streams/rows/axes/chains; calendar = current local month (`YYYY-MM`); default sets Base / Downside / Upside. `model_new` allocates a `viewId` (`mv-…`) when omitted and returns `viewUrl`.

`model_upsert_parameter`: catalog columns (`streamId`, `role`, `layer`, `primaryOwner`, `formula`) plus optional top-level `name` / `definition` / `unit` / `valueType` / `scopeAxes` / `priority` / `canonicalId` / `domainId` / `comment` / `assignedWs` / `economicClass` / `qtyParameterId` / `rateParameterId` / `rateComponents` / `adjustmentKind`. Other fields still go in `attrs`. On **update**, omitted keys keep their previous attrs (do not resend the whole attrs blob unless you intend to replace those keys).

**Load demo:** `js/model/demo-data.js` — thin WS2 kernel (not the 144-row register). See §12.

Workflow: `model_open` / `model_new` → `model_upsert_parameter` / `model_upsert_parameters` (catalog) → `model_upsert_observation` / `model_upsert_observations` (facts) → `model_upsert_edge` (manual links) → `model_evaluate` (read-only). Never whole-document upsert.

---

## 4. Entity catalog

### 4.1 Project

```
{
  id: string,            // edit id, e.g. mp-…
  viewId?: string,
  title: string,         // max 160
  calendar: ProjectCalendar,
  rev: number
}
```

### 4.2 Stream

```
{ id: string, name: string, order: number, color?: string }
```

`name` max 80. Palette (default picker): `#0f766e` `#b45309` `#0369a1` `#4d7c0f` `#9f1239` `#6d28d9` `#0e7490` `#a16207`.

### 4.3 Parameter (`ModelRow`) — catalog

| Field | Type / closed list | Notes |
|-------|--------------------|--------|
| `id` | `^[a-zA-Z][a-zA-Z0-9_.-]*$` | Formula key `[id]`. Max 64. No `\|`. |
| `name`, `nameRu` | string, max 160 | Display. Refs may also resolve by unique name (case-insensitive). |
| `canonicalId` | string | Optional registry code (e.g. `D10.IN.01`). Formulas may use a **unique** `[canonicalId]`. |
| `streamId` | string | Parent stream |
| `order` | number | Sort within stream |
| `layer` | `L0`…`L7` | Taxonomy only. Default `L2`. |
| `domainId` | `D01`…`D17` \| `GOV` | See §7. Domain name is filled from the dictionary. |
| `domainName` | string | Usually derived |
| `groupId` | string | Optional group code |
| `role` | `IN` \| `CALC` \| `OUT` \| `IN+OUT` \| `CALC+OUT` | See §8 |
| `priority` | `P0` \| `P1` \| `P2` | Default `P1`. P0 empty + no formula → warning. |
| `definition` | string, max 2000 | Required (warning if blank) |
| `valueType` | `number` \| `percent` \| `currency` \| `category` \| `date` \| `boolean` | |
| `unit` | string, max 40 | Required (warning if blank) |
| `currency` | string, max 8 | For `currency` rows (USD, BYN…) |
| `primaryOwner`, `assignedWs` | string | Suggested: §7 owners |
| `formula` | string, max 500 | Leading `=`. CALC required unless SBC-computed. |
| `economicClass` | `quantity` \| `rate` \| `adjustment` \| `cost` \| `variance` \| `other` | Orthogonal to role |
| `qtyParameterId`, `rateParameterId` | parameter id | SBC **cost** links |
| `rateComponents` | `{ parameterId, op: 'add'\|'subtract' }[]` | SBC **rate** |
| `adjustmentKind` | `discount` \| `preference` \| `credit` \| `surcharge` \| `tax` | When class is `adjustment` |
| `scopeAxes` | string[] | Axis IDs this row is keyed by. Do **not** include Set. Period/Phase are reserved generated axes. |
| `dependsOn` | string[] | Derived on save (formula + SBC). Do not author by hand. |
| `validationDecision` | `not_reviewed` \| `accepted` \| `rejected` \| `replace` | |
| `validator`, `validatedAt`, `validationRule` | strings | Review metadata |
| `comment` | string, max 1000 | Notes |
| `addressees` | string[] | OUT delivery list — **not** edges |
| `valueOverride` | boolean | Row-level; per-obs override is on the observation |

REST/MCP catalog payload: indexed columns `parameterId`, `streamId`, `role`, `layer`, `primaryOwner`, `formula` plus `attrs` JSON for the rest. Observations are **not** in the catalog write.

### 4.4 Observation (fact)

```
{
  scope: Record<string, string>,   // axisId → memberId
  setId?: string,                  // default / empty → Base
  value: string | number | boolean | null,  // stored as string, max 200
  evidenceStatus: EvidenceStatus,
  valueLocked?: boolean,
  source: { sourceId, locator, date, owner, allowedUse },
  valueOverride?: boolean          // CALC: keep this value instead of formula
}
```

**Unique key:** `(project_id, parameter_id, scope_key, set_id)`.

**Canonical `scope_key`:** axis order from `scopeAxes`, each part `axisId=encodeURIComponent(memberId)`, joined by `|`.  
Examples: `""` (global), `Phase_ID=P1`, `Workload_ID=training`, `Phase_ID=P2|Workload_ID=inference`.

Empty `setId` canonicalizes to `Base`.

### 4.5 Axis

```
{
  axisId: string,
  name: string,
  members: { id, label, start?, end?, hours?, extra?: { min?, max? } }[],
  generated?: boolean
}
```

Reserved generated axes (from calendar — do not add by hand on new rows):

- `Period_ID` — members = generated periods (`2027-01`, `2027-Q1`, `2027`)
- `Phase_ID` — members = project phases (`P1`, `P2`, …)

Suggested free-axis ids (registry starts empty; these are picker hints):  
`Workload_ID`, `Phase_ID`, `Scenario_ID`, `Site_ID`, `Period_ID`, `SKU_ID`, `RTP_ID`, `Capacity_Cohort_ID`, `Compute_Config_ID`, `Rack_Profile_ID`, `Hall_ID`, `Scale_Size`, `Data_Class_ID`, `Storage_Profile_ID`, `Fabric_Profile_ID`, `Platform_Profile_ID`, `Segment_ID`.

**Sets are not axes.** Do not create a `Set_ID` / `Scenario_ID` axis to store Base/Downside/Upside.

### 4.6 Calendar / periods / phases / sets

```
ProjectCalendar = { granularity: 'month'|'quarter'|'year', start: 'YYYY-MM', end: 'YYYY-MM' }
Phase           = { phaseId, label, siteId?, startPeriodId, endPeriodId, capacityMw? }
ParameterSet    = { setId, label, scenarioId, parentSetId?, locked }
```

- Max **120** generated periods.
- Period hours: UTC month length (`hoursInMonth`). Quarter/year sum months.
- `CalendarHours()` in a formula → hours of the **active period** (or each period in a range).
- Active **Phase** is derived from the selected period (and `Site_ID` if used).
- Default sets (every new project):

| setId | parent | Notes |
|-------|--------|--------|
| `Base` | (none) | **Required** on every observation slice |
| `Downside` | `Base` | Blank value → inherit Base |
| `Upside` | `Base` | Blank value → inherit Base |

Inheritance walks `parentSetId` until Base.

### 4.7 Edge

```
{
  edgeId: string,
  fromId: string,
  toId: string,
  edgeType: 'FORMULA' | 'SOURCE' | 'FLOW' | 'INTERFACE',
  condition: 'DIRECT' | 'CONDITIONAL',
  label?: string,
  owner?: string
}
```

- Unique persistable pair: `(fromId, toId, edgeType)`.
- **FORMULA** edges are derived from `[refs]` / SBC deps (`formulaEdgeId(from,to)`). Client-only. MCP/REST reject them.
- Manual types: SOURCE, FLOW, INTERFACE. CONDITIONAL shows `?` and dims until source evidence satisfies the condition.

### 4.8 Chain

```
{
  chainId: string,        // e.g. CHAIN-POWER
  name: string,
  memberIds: string[],    // ordered parameter IDs
  destination?: string,   // KPI parameter ID
  stubNote?: string
}
```

Evaluation (`edges.js`):

| Structure | Meaning |
|-----------|---------|
| `MISSING` | A step id is not in the model |
| `GAP` | No edge (any type) between adjacent steps |
| `PASS_STRUCTURE` | Adjacent pairs all have an edge |

Evidence rollup along the path: `BLOCKED` / `PARTIAL` / `PASS`.

**Seed chain ids** (empty members until demo/user fills them):  
`CHAIN-POWER`, `CHAIN-RACK`, `CHAIN-COMPUTE`, `CHAIN-TOKEN`, `CHAIN-PRICE`, `CHAIN-CAPEX`, `CHAIN-OPEX`, `CHAIN-FIN`, `CHAIN-ECML`.

---

## 5. MySQL register schema

MySQL 8, utf8mb4. JS/REST names on the left; DB column notes in parentheses.

| Table | Key | Columns |
|-------|-----|---------|
| `model_projects` | `id` | `view_id`, `title`, `calendar` JSON, `rev` |
| `model_streams` | `(project_id, stream_id)` | `name`, `sort_order`, `color`, `rev` |
| `model_axes` | `(project_id, axis_id)` | `name`, `is_generated` (JS: `generated`), `rev` |
| `model_axis_members` | `(project_id, axis_id, member_id)` | `label`, `extra` JSON, `sort_order` |
| `model_phases` | `(project_id, phase_id)` | `label`, `site_id`, `start_period_id`, `end_period_id`, `capacity_mw`, `rev` |
| `model_parameter_sets` | `(project_id, set_id)` | `label`, `scenario_id`, `parent_set_id`, `locked`, `rev` |
| `model_parameters` | `(project_id, parameter_id)` | `stream_id`, `param_role` (JS: `role`), `layer`, `primary_owner`, `formula`, `attrs` JSON, `rev` |
| `model_observations` | `(project_id, parameter_id, scope_key, set_id)` | `scope` JSON, `value` JSON, `evidence_status`, `value_locked`, `value_override`, `source_json` (JS: `source`), `rev`. `scope_key` VARCHAR(512). |
| `model_edges` | `(project_id, edge_id)` | `from_id`, `to_id`, `edge_type`, `edge_condition`, `label`, `owner`, `rev`. Unique `(from_id, to_id, edge_type)`. |
| `model_chains` | `(project_id, chain_id)` | `name`, `destination`, `stub_note`, `rev` |
| `model_chain_steps` | `(project_id, chain_id, position)` | `parameter_id` |

Cascade: delete project → all children; delete parameter → its observations; delete chain → its steps.

---

## 6. In-memory `ModelState`

```
{
  title: string,
  streams: Stream[],
  rows: ModelRow[],          // parameters + nested observations
  edges: ModelEdge[],        // includes ephemeral FORMULA edges
  axes: ScopeAxis[],
  chains: ModelChain[],
  calendar: ProjectCalendar,
  phases: Phase[],
  parameterSets: ParameterSet[],
  selectedRowId: string | null
}
```

Hydrate rebuilds this from records. Compute fills a `Map` keyed by `` `${rowId}::${scopeKey}::${setId}` ``. Those cells are **not** written back.

---

## 7. Dictionaries and predefined values

Closed lists live in `js/model/constants.js`. Unknown values normalize to the default shown.

### Role

| Value | Meaning |
|-------|---------|
| `IN` | Manual / sourced. **No formula.** Facts in observations. |
| `CALC` | Formula required (or SBC structural compute). Value computed. Evidence = ceiling. |
| `OUT` | Output / delivery contract. `addressees` is a list, not edges. |
| `IN+OUT` | Input + output |
| `CALC+OUT` | Computed + output |

Adding a formula to IN asks to promote → CALC (`IN+OUT` → `CALC+OUT`).

### Layer / priority / value type

- **Layer:** `L0` `L1` `L2` `L3` `L4` `L5` `L6` `L7` (taxonomy only)
- **Priority:** `P0` `P1` `P2`
- **Value type:** `number` `percent` `currency` `category` `date` `boolean`

### Evidence (weak → strong)

`UNKNOWN` < `CONTESTED` < `ASSUMPTION` < `PROVISIONAL` < `VALIDATED` < `APPROVED`

CALC status = **min** of input statuses (`ceilingFrom` lists who set it). Do not set CALC evidence by hand.

Rules:

- Empty IN value must stay `UNKNOWN`.
- `VALIDATED` / `APPROVED` need `sourceId` **or** `locator`.
- `APPROVED` also needs `source.date`.
- `APPROVED` cannot use allowed use `benchmark substitute`.
- Non-UNKNOWN without locator → warning.
- `valueLocked` blocks editing the number; it is **not** APPROVED.

**Allowed use:** `project fact` | `benchmark substitute` | `diagnostic only`.

**Validation decision:** `not_reviewed` | `accepted` | `rejected` | `replace`.

### SBC economic class

| Class | Badge | Wiring |
|-------|-------|--------|
| `quantity` | Q | Cost qty side. Currency in `unit` → warning. |
| `rate` | R | Optional `rateComponents` (`add` / `subtract`). Discount/preference adjustments apply as percent (`rate × (1 ± PCT(adj))`) when the peer is `economicClass=adjustment` and unit is `%` or `valueType=percent`; else `sbc_rate_unit_mix` + empty. Other kinds stay same-family add/subtract. Mixed non-percent units → error. |
| `adjustment` | A | `adjustmentKind` required for meaning. |
| `cost` | C | `qtyParameterId` + `rateParameterId` → compute `qty * rate` **only when formula is empty**. `unitFamily(qty) !== unitFamily(rate)` → warn `sbc_qty_rate_unit_mismatch` and **empty cell** (no MWh→kWh invent). Missing currency → warning. |
| `variance` | V | Taxonomy |
| `other` | (none) | Default |

**Adjustment kinds:** `discount` `preference` `credit` `surcharge` `tax`.

**Rate component ops:** `add` `subtract`.

### Domains

| id | name |
|----|------|
| D01 | Market / Customer Demand |
| D02 | Product / Pricing / Commercial Contracts |
| D03 | Workload / SLA / QoS |
| D04 | Capacity / Scheduling / Utilization |
| D05 | Compute / Accelerator / Memory |
| D06 | Data / Storage / Data Lifecycle |
| D07 | Network / Fabric / Connectivity |
| D08 | AI Platform / Orchestration / Observability |
| D09 | Security / Compliance / Responsible AI |
| D10 | Power / Grid / Energy |
| D11 | Cooling / Water / Heat Recovery |
| D12 | Facility / MEP / Site / Resilience |
| D13 | Operations / SRE / Service Management |
| D14 | Supply Chain / Procurement / Technology Lifecycle |
| D15 | Integrated Cost & Lifecycle Ledger |
| D16 | Financing / Tax / Incentives |
| D17 | Financial / Scenario / Risk / Investment |
| GOV | Government Relations / State Support & Project Facilitation |

### Owners (suggested)

`WS1` `WS2` `WS3` `WS4` `WS5` `WS6` `WS7` `WS9` `PMO` `WS1+WS2` `WS2+WS4` `WS1+WS2+WS4`

### Edges / chains

- Edge types: `FORMULA` `SOURCE` `FLOW` `INTERFACE`
- Conditions: `DIRECT` `CONDITIONAL`
- Structure: `MISSING` `GAP` `PASS_STRUCTURE`
- Rollup: `BLOCKED` `PARTIAL` `PASS`

### Calendar

- Granularity: `month` `quarter` `year`
- Period ids: `YYYY-MM` / `YYYY-Qn` / `YYYY`
- Timeline UI mode: `period` | `range` (prefs only)

### Limits

| Limit | Value |
|-------|--------|
| Parameter id | 64 |
| Name | 160 |
| Definition | 2000 |
| Formula | 500 |
| Stored value | 200 |
| Comment | 1000 |
| Stream name | 80 |
| Generated periods | 120 |
| Cartesian scope expand (CALC from deps) | 200 |
| `model_evaluate` cells | 2000 |

---

## 8. Formula language and compute rules

Engine: `js/model/formula.js` + `js/model/formula-parse.js`. Closed AST (no `eval`). Unknown tokens → empty cell + `formula_invalid` (not `0`).

### Syntax

1. Formula string **must start with `=`**. CALC still requires `=` or SBC. There is no `formulaStatus`. Methodology-only / prose equations stay **IN + `comment`** (optional empty `formula`).
2. Refs resolve in order: exact **`id`** → unique **`canonicalId`** → unique **name** (case-insensitive). Duplicate `canonicalId` or name → unresolved + warn `formula_alias_ambiguous` (do not guess). Aliases do **not** merge catalog rows.
3. Before parse, normalize `×` → `*`, `÷` → `/`, Unicode minus / en-dash → `-`. Leftover letter-like operators are invalid.
4. Operators: `+ - * / ^` (power), unary `+/-`, parentheses. Comparisons `>= <= > < == !=` are for `IF` conditions (or a boolean-typed result). Boolean combinators are functions only (`AND`/`OR`/`NOT`); no `&&` / `||`.
5. Closed functions (case-insensitive): `IF`, `CEILING`, `FLOOR`, `MAX`, `MIN`, `POW`, `PCT`, `CalendarHours`, `YearsFromBase`, `YEARFRACTION`, `BAND`, `LOOKUP`, `AND`, `OR`, `NOT`, `SUM`, `NPV`, `IRR`, `PAYBACK`, `DSCR`.

| Function | Meaning |
|----------|---------|
| `IF(cond, a, b)` | `cond` is a comparison or numeric (0 false). Result follows `a`/`b` (number or boolean). |
| `CEILING(x, significance)` / `FLOOR(x, significance)` | Excel-style; default significance `1`. |
| `MAX(...)` / `MIN(...)` | Two or more args. |
| `POW(a, b)` | Same as `a^b`. |
| `PCT(x)` | `x/100`. Use for 0–100 percent facts in `(1 - PCT([discount]))`. Do **not** auto-scale `valueType=percent` (demo `LF` is already `0.70` as `number`; `PRJ.ANCHOR_SHARE` is `70` as `percent`). |
| `CalendarHours()` | Hours of the **active period**. |
| `CalendarHours(year)` | Hours in the civil year of the active period (`hoursInYear`). Keyword `year`, not a string. |
| `CalendarHours(range)` | Sum of hours over the UI range. Empty if the view is not range mode. |
| `YearsFromBase()` | Whole years from calendar `start` to the active period (0 in the start month). |
| `BAND([qtyParam], rateParamId)` | On `rateParam`’s first non-Period/Phase scope axis, find the member whose `extra.min` / `extra.max` contain qty (`min <= qty < max`; last band may omit max). Return that observation (set inheritance unchanged). No unit conversion. |
| `LOOKUP(memberIdOrRef, rateParamId)` | Exact member on that axis. |
| `AND(a, b, …)` / `OR(a, b, …)` | ≥2 args. Same truthiness as `IF`. Empty/invalid arg → empty (not false). `XOR` is invalid. |
| `NOT(a)` | One arg. Empty → empty. |
| `YEARFRACTION()` / `YEARFRACTION(periodId)` | `monthsFromStart / 12` (month), `/4` via months (quarter), year index (year). Active-period call uses active `Period_ID`. Non-negative. |
| `SUM([series])` | Sum of generated period (or phase) members. Range mode / evaluate range uses those ticks; otherwise all members. Missing/non-numeric periods **omitted**, not 0. |
| `NPV(rate, [series])` | `Σ CF_t / (1+r)^{YearFraction(t)}`. `rate` is annual effective (`0.10`; use `PCT` if stored as 10). |
| `IRR([series])` | Newton + bisection; 50 iters, tol `1e-7`. Empty if fewer than two points or no sign change. |
| `PAYBACK([series])` | `YearFraction` of the first member with cumulative CF ≥ 0. |
| `DSCR(cads, debt)` | `cads/debt` for the **active period**. `/0` → empty. |

Axis member `extra` for tariff bands (G16): `{ min, max }` numeric. Persist via `model_patch_project` axes (or seed). Example: `=BAND([QTY], [RATE.ELEC.G16])`.

EQ-05 (units differ): `=[RATE.ELEC.G1.ENERGY]*(1-PCT([RATE.ELEC.DISCOUNT]))`. Do not invent BYN−BYN discount.

SBC: qty×rate and rate add/subtract apply when `economicClass` is wired **and** the formula does not start with `=`. If both are set, the AST runs and validation warns `sbc_formula_override`.

**Deps / FORMULA edges:** `[refs]` plus `BAND`/`LOOKUP` table ids. `depsFromFormula` sees them so delete-guards and the graph stay correct.

Examples:

```
=[PRJ.FACILITY_MW]/[PUE]
=[IT_MW]*[LF]
=[IT_MW]*CalendarHours()
=[RATE.ELEC.G1.ENERGY]*(1-PCT([RATE.ELEC.DISCOUNT]))
=IF(AND([LF]>0,[PUE]>1), TRUE, FALSE)
=NPV(0.1,[CF])
=IRR([CF])
=PAYBACK([CF])
=SUM([CF])
=IF([A]>=[B], CEILING([A]/[C],1), 0)
=BAND([QTY], [RATE.ELEC.G16])
=[BASE]*(1+[GROWTH])^YearsFromBase()
```

### Out of Model (do not invent here)

Not computed in this engine — leave as **IN + comment**, or compute elsewhere:

- Rack-mix optimizer / EQ-16, reverse EQ-13–15
- XNPV / XIRR, WACC calculator
- ECML event graph (`CHAIN-ECML` stays a stub)
- Master Hub process layers
- Unit conversion, `%` as an operator, `&&` / `||`, a second formula direction / solver

### Who computes

| Role / class | Engine |
|--------------|--------|
| CALC / CALC+OUT with `=` | Closed formula AST (wins over SBC) |
| `economicClass=cost` + qty + rate, formula empty | `qty * rate` (SBC). Unit-family mismatch → empty |
| `economicClass=rate` + `rateComponents`, formula empty | Base + add/subtract; discount/preference as percent |
| IN / IN+OUT | Lookup observation (no formula). Narrative equations stay here + `comment`. |

### Scope resolution (broadcast)

When evaluating parameter A which depends on B:

- If B has **no** `scopeAxes` (global), B’s value **broadcasts** into every slice of A.
- If B is scoped, every axis on B must be present in the evaluation context. Incomplete → empty cell.
- `Period_ID` in context derives `Phase_ID` from the project phase windows (and site if used).
- A **phase-scoped** input is chosen by the active period’s phase. The month does not need its own observation.

Demo: global `PUE` into phase-scoped `IT_MW`; global `D03.P012` into each Workload slice of `D03.P013`.

### Set resolution

Dependencies evaluate on the **same set**, then walk `parentSetId` (Downside → Base). Blank child observation = inherit, not zero.

### Empty, cycles, overrides

- Missing dep, non-numeric dep, unresolved `[ref]`, `NaN` / non-finite → cell `empty`, value `null`, evidence `UNKNOWN`.
- Cycle → `empty` + `cycle: true`.
- Division by zero → empty (`div_zero`).
- Evaluate / UI cells may carry `emptyReason`: `missing_dep` \| `unresolved_ref` \| `formula_invalid` \| `cycle` \| `div_zero` \| `incomplete_scope` \| `sbc_qty_rate_unit_mismatch` \| `sbc_rate_unit_mix`.
- CALC `valueOverride` on the matching observation: stored value wins; evidence ceiling still from inputs.

### Evidence ceiling

`status = min(dep statuses)` by the rank in §7. `ceilingFrom` lists deps that share that weakest rank.

### Range mode (UI)

Period-scoped CALC numbers/currency **sum** across the selected period range. Phase-scoped IN with more than one phase in range may display **mixed**. Prefs only — does not change stored facts.

### FORMULA edges

Synced from formula refs + SBC deps. Read-only on the graph. Never write them via MCP/REST.

---

## 9. Validation (errors vs warnings)

| Code | Level | When |
|------|-------|------|
| `in_has_formula` | error | IN / IN+OUT has a formula |
| `calc_no_formula` | error | CALC without formula and not SBC-computed. There is no `formulaStatus: pending/narrative` — leave methodology-only equations as IN + `comment`, or keep CALC only with a legal `=` / SBC. |
| `formula_invalid` | error | Parse fail, unknown function, or leftover unicode |
| `formula_alias_ambiguous` | warn | `[ref]` matches more than one `canonicalId` or name |
| `base_set_required` | error | IN slice missing Base value |
| `empty_non_unknown` | error | Empty value with status ≠ UNKNOWN |
| `status_needs_locator` | error | VALIDATED/APPROVED without source id/locator |
| `approved_needs_date` | error | APPROVED without source date |
| `benchmark_not_fact` | error | APPROVED + allowed use benchmark substitute |
| `sbc_rate_unit_mix` | error | Rate components have different unit families (or a discount/preference is not `%` / percent) |
| `sbc_formula_override` | warn | Formula starts with `=` and SBC links are also set — AST wins |
| `sbc_qty_rate_unit_mismatch` | warn | Cost qty and rate unit families differ — cell empty |
| `no_evidence` | warn | Non-UNKNOWN without locator |
| `missing_required` | warn | Blank definition or unit |
| `p0_empty` | warn | P0 with no value and no formula |
| `sbc_cost_missing_links` | warn | Cost class without qty/rate |
| `sbc_qty_currency_unit` | warn | Quantity unit looks like currency |
| `sbc_cost_no_currency` | warn | Cost without currency code |

Delete parameter is blocked if another formula/SBC still references it.

---

## 10. Compute pipeline

1. `normalizeState()` — axes, chains, calendar, period slots, formula deps, FORMULA edges  
2. `computeModel(rows, state, options)` — per set, closed formula AST (`AND`/`OR`/`NOT`, `YEARFRACTION`, `SUM`/`NPV`/`IRR`/`PAYBACK`/`DSCR`, `CalendarHours(year|range)`, `YearsFromBase`, `BAND`/`LOOKUP`, …), phase-from-period, cartesian expand of CALC scopes (cap 200)  
3. `displayCellForSlice` / timeline helpers — value + evidence for the active view  
4. Chain evals + evidence ceilings on CALC rows  
5. `model_evaluate` / `POST …/evaluate` — same `computeModel` via `docToComputeState` + `evaluateState` (default period = calendar start; cell cap 2000)

View context = UI `activeSlice` + `activePeriodId` / range + `activeSetId`. Evaluate builds the same options from the query (no `ui-prefs` / `store`).

---

## 11. Interface map (chrome + drawer)

Use these names when telling a human where to click. None of this is stored in MySQL except the data the controls edit. Layout prefs stay in `roadflow-model-ui-v1`.

### Chrome

| Surface | When visible | Capability |
|---------|--------------|------------|
| **App header** | Always | Help (?): Instructions EN/RU, About, this file. Locale `en`/`ru`. Theme. |
| **Top bar** | Always | **New** — empty model, unbind project, calendar = current month. **Load demo** — WS2 fixture, also unbinds. |
| **Empty screen** | No streams | **+ Stream** or Load demo. Model/View bars hidden. |
| **Model bar** | ≥1 stream | Project dictionaries: **Axes**, **Chains**, **Calendar**, period/range ticks, **Graph**. |
| **View bar** | ≥1 parameter | Active view: **Fields**, **Filter**, **Set**, free-axis dropdowns, **Expand slices**. |
| **Table** | Has streams | Title; one stream block (collapse, color, reorder, + Row, delete). Inline cells + row select → drawer. VALUE/evidence follow active view. Fill badge `filled/total` on scoped rows. |
| **Drawer** | Row selected | Full editor for one parameter (panels below). |
| **Graph panel** | Graph pressed | Stream columns, cards, edges, chain dashboard, Selected only, pan/zoom, splitter. |
| **Modals** | Button | Axes / Chains / Calendar / Help. Escape closes. Project-level, not one observation. |

**Model bar — why each control**

- **Axes** — Timeline (phase name + start/end among generated periods), Sets (count only), Slice axes (add Workload/Site… + members). Period is generated by Calendar and is not listed. Sets are **not** axes.
- **Chains** — Create ordered paths + destination KPI. Structure = adjacency has any edge; evidence rollup is separate.
- **Calendar** — `month`/`quarter`/`year`, start/end, Rebuild timeline; Set catalog. Regenerates `Period_ID` members and period slots.
- **Period / Range ticks** — Active time. Period pins one tick (formulas + `CalendarHours()` / `CalendarHours(year)`). Range aggregates period-scoped CALC and fills `CalendarHours(range)`; phase-scoped IN may show `mixed`. Active Phase = phase that contains the selected period.
- **Graph** — Toggle right panel; width saved.

**View bar — why each control**

- **Fields** — Column visibility/order. ID + Name pinned. Does not change records.
- **Filter** — Hide rows from the table (model unchanged; graph dims).
- **Set** — Which observation value the UI and compute display (`Base` / `Downside` / `Upside`). Not an axis.
- **Slice dropdowns** — Free axes in use. Unused-on-selected-row appear dimmed. Period/Phase are not here.
- **Expand slices** — One table row per scope (not per Set). Hides free-axis dropdowns; Set stays.

**Graph panel**

- One column per stream; stack by `order`.
- Card: name, active-slice value, evidence, fill `filled/total` (computed cells count for CALC).
- Click → select (table scrolls, drawer opens). Drag 24px grid, session-only. Reset on add/remove row or Selected-only — **not** on selection/filter/chain highlight.
- Selection or active chain **dims** others unless `graphSelectedOnly` (then hides).
- Same stream → left loop; different streams → right of source to left of target (`streamId`, not pixel X).
- Wheel zoom; empty-canvas pan. Click an edge for type/condition. FORMULA is read-only. Dashed red = chain GAP.

### Drawer panels (`data-drawer-section`)

Open on table-row or graph-card select. Sections collapse independently (`collapsedDrawerSections`). Head stays pinned.

| Section | Edits | Why it exists |
|---------|-------|----------------|
| **Head** (not a collapsible) | Active-slice Base/Downside/Upside (IN) or computed display (CALC). Close. | Edit the working number without scrolling. Syncs with Value. Filter banner + Clear filters. CALC ceiling line. |
| **Identity** | `id`, `name`, `nameRu`, `layer`, `canonicalId` | ID is the formula/edge/chain key (rename rewrites refs). Formulas may also use a unique `[canonicalId]` or unique name. Layer is taxonomy. Canonical ID is an external register code — it does not merge two catalog rows. |
| **Classification** | `domainId`, `groupId`, `role`, `priority` | Place on the methodology map. Role = lookup vs compute. Priority = P0 must-have. |
| **Definition** | `definition`, `valueType`, `unit`, `currency` | Contract of the number. Missing definition/unit → warning. Type controls parse/display. |
| **Ownership** | `primaryOwner`, `assignedWs` | Accountability / who fills it. Filters only — not compute. |
| **Value** | Active-slice set values (IN), formula, override, ref chips | Working number for the current view. Formula is the CALC contract. Override is the only stored CALC value. Incomplete/mixed shows a hint, not a fake number. |
| **SBC** | `economicClass`, qty/rate ids, `rateComponents`, `adjustmentKind` | Structural cost/rate (qty×rate, add/subtract). Orthogonal to role. Drives Q/R/A/C badges. |
| **Scope** | Observations table (scope × Set values) | Facts live here. One row per scope, columns per Set. View-bar dropdowns only *select* the active slice. |
| **Evidence** | Status, lock, source {id, locator, date, owner, allowedUse}, validation*, `comment` | Provenance. CALC status is read-only (ceiling). Lock ≠ APPROVED. |
| **Status ceiling** | Read-only (CALC) | Which inputs set `min(evidence)`. Click jumps to that parameter. |
| **Edges** | List in/out; add SOURCE/FLOW/INTERFACE; delete manual | FORMULA syncs from formula. Manual edges are narrative/physical links so chains can PASS STRUCTURE. |
| **Chains** | Membership view + open manager | Local view of lineage paths that include this row. |
| **Downstream** | `addressees` (comma list) | OUT delivery contract. **Does not** create edges. |

### UI prefs (local only)

`visibleColumns`, `filters`, `activeSlice`, `activeSetId`, `activePeriodId`, `periodRangeStart` / `End`, `timelineMode`, `expandSlices`, `graphOpen`, `graphWidthPct`, `graphSelectedOnly`, `activeChainId`, `chainDashboardCollapsed`, `collapsedStreams`, `collapsedDrawerSections`.

---

## 12. Demo fixture (Load demo)

Title: `WS2 kernel slice — 300 MW power chain + D03 demand + finance`.  
Calendar: `2027-01`…`2027-06`, month.  
Phases: `P1` Jan–Mar, `P2` Apr–Jun.  
Sets: Base / Downside / Upside.  
Axis: `Workload_ID` = `training` | `inference`.  
Time grain of the power chain is **Phase**, not Period. Cash-flow rows use **Period**.

### Streams

| id | name |
|----|------|
| `D10` | Power / Grid / Energy |
| `RATES` | Rates / control |
| `D03` | Workload / SLA / QoS |
| `D17` | Financial / Scenario |

### Parameters

| id | Role | Scope | Unit | Formula / fact |
|----|------|-------|------|----------------|
| `PRJ.FACILITY_MW` | IN | Phase | MW facility | P1 Base 100 (Downside 80, Upside 120); P2 Base 300 (inherits on blank sets). P2 VALIDATED (Customer Protocol). WS3 310 MW left as a comment contradiction. |
| `PUE` | IN | global | ratio | Base 1.30; Downside 1.40. ASSUMPTION WS3 A-1-013. |
| `IT_MW` | CALC | Phase | MW IT | `=[PRJ.FACILITY_MW]/[PUE]` → ~76.9 / ~230.8 |
| `LF` | IN | global | ratio | Base 0.70; Downside 0.55. **Not** `PRJ.ANCHOR_SHARE`. |
| `E_IT` | CALC | Phase | MW IT | `=[IT_MW]*[LF]` |
| `E_fac` | CALC | Phase | MW facility | `=[E_IT]*[PUE]` |
| `RATE.ELEC.G1.ENERGY` | IN | global | BYN/kWh | 0.16 VALIDATED |
| `RATE.ELEC.DISCOUNT` | IN | global | % | 0 (preference, `valueType=percent`). Apply with `PCT`. |
| `RATE.ELEC.EFFECTIVE` | CALC | global | BYN/kWh | `=[RATE.ELEC.G1.ENERGY]*(1-PCT([RATE.ELEC.DISCOUNT]))` (formula wins; SBC components remain) |
| `Cost_elec` | CALC | Phase | BYN | Formula `=[E_fac]*[RATE.ELEC.EFFECTIVE]` (wins over SBC; qty/rate families differ) |
| `RACK.ANNUAL_FAC_KWH` | IN | global | kWh/y | 1_000_000 |
| `RACK.ENERGY_COST` | CALC | global | BYN/y | SBC-only (no `=`): qty × `RATE.ELEC.EFFECTIVE` |
| `PRJ.ANCHOR_SHARE` | IN | global | % facility | 70. Control parameter — **not** an energy input. |
| `MKT.B200.PUBLIC_GPUH` | IN | global | USD/GPU-h | 4.25 PROVISIONAL, benchmark only — **not** wired to demand. |
| `D03.P009` | IN | Workload | workload unit/month | training 250; inference 80. Diagnostic. |
| `D03.P012` | IN | global | x | 1.20. Broadcasts into P013. |
| `D03.P013` | CALC | Workload | workload unit | `=[D03.P009]*[D03.P012]` |
| `DEMO.AND_OK` | CALC | global | boolean | `=IF(AND([LF]>0,[PUE]>1), TRUE, FALSE)` |
| `CF` | IN | Period | BYN | Monthly series −100, 10, 20, 30, 40, 50 |
| `FIN.NPV` | CALC | global | BYN | `=NPV(0.1,[CF])` |
| `FIN.IRR` | CALC | global | | `=IRR([CF])` |
| `FIN.PAYBACK` | CALC | global | year | `=PAYBACK([CF])` |
| `FIN.SUM` | CALC | global | BYN | `=SUM([CF])` |

### Manual edge

`RATE.ELEC.DISCOUNT` → `RATE.ELEC.EFFECTIVE`, `SOURCE` + `CONDITIONAL`, owner WS7.

### Chains

- `CHAIN-POWER`: `PRJ.FACILITY_MW` → `IT_MW` → `E_IT` → `E_fac` → `Cost_elec` (destination `Cost_elec`)
- `CHAIN-COMPUTE`: `D03.P009` → `D03.P013` (destination `D03.P013`)
- `CHAIN-FIN`: `CF` → `FIN.NPV` / `FIN.IRR` / `FIN.PAYBACK` / `FIN.SUM` (destination `FIN.NPV`)

---

## 13. Agent guidelines

### Do

- Read this file and the demo fixture before generating a similar model.
- When directing a human, use chrome and drawer section names from §11 (e.g. “open the drawer → Evidence”, “Model bar → Calendar”).
- Create axes and sets **before** observations. Then one observation per `(parameter, scope, set)`.
- Keep parameter IDs stable. Rewrite formulas when renaming (`rewriteFormulaRef`).
- Use Sets for scenarios. Do not duplicate rows per Base/Downside/Upside.
- Put Base on every IN slice. Leave child sets blank to inherit.
- Document assumptions with `ASSUMPTION` + `source.locator` / `sourceId`.
- Patch one record at a time (`model_upsert_observation`, etc.).

### Avoid

- Whole-document JSON upsert (Plans-style).
- Persisting CALC outputs or FORMULA edges.
- Duplicate observations for the same scope+set.
- Setting CALC `evidenceStatus` (computed).
- Treating Set as an axis, or Period/Phase as View-bar axes you invent.
- `|` or invalid characters in parameter IDs.
- Feeding `PRJ.ANCHOR_SHARE` or `MKT.B200.PUBLIC_GPUH` into the power/demand formulas unless the user explicitly changes the model.

### Editing

1. MCP: `model_open` / `model_new`, then one-record upserts.  
2. Or REST `PUT /v1/model-projects/:id/observations`.  
3. Hydrate: `model_get` (`include=all`) or `GET /v1/model-projects/:id`.  
4. UI prefs only: `localStorage` key `roadflow-model-ui-v1`.

---

## 14. Code map

| Area | Files |
|------|--------|
| Entry | `js/model/main.js`, `model/index.html` |
| State | `js/model/state.js`, `store.js`, `persistence.js`, `record-codec.js`, `cloud-register.js` |
| Dictionaries | `js/model/constants.js` |
| Register API | `server/api-model.js`, `server/db/model-register.js`, `server/db/schema.js` |
| MCP | `server/mcp/model-tools.js` |
| Table + graph | `js/model/render.js` |
| Formulas | `js/model/formula.js` |
| Calendar / sets | `js/model/calendar.js` |
| Scope / obs | `js/model/scope.js` |
| Edges / chains | `js/model/edges.js` |
| Validation | `js/model/validation.js` |
| Demo | `js/model/demo-data.js` |
| i18n | `js/model/i18n/messages-en.js`, `messages-ru.js` |
| Help | `js/model/help-content.js` |

---

## Contact

admin@roadflow.io
