|
| 1 | +# Savant: AnalyticDistributionSuggester (id 4 · family 0x62 · lane L10) |
| 2 | + |
| 3 | +**Tuple:** kind=NextBestAction · inference=Induction · semiring=NarsTruth · style=Analytical |
| 4 | +**Feeds Reasoner impl:** `NextBestActionReasoner` (per the impl-per-ReasoningKind decision) |
| 5 | + |
| 6 | +> dispatch: `ReasoningKind::NextBestAction` -> "induce the action with the highest expected value" |
| 7 | +> (`examples/savant_dispatch.rs:32`). Induction -> `QueryStrategy::CamWide`. Style Analytical |
| 8 | +> inherited from 0x62 SMBAccounting. |
| 9 | +
|
| 10 | +## What it decides (AXIS-B core) |
| 11 | +For a `display_type == 'product'` move line (or any line on a non-invoice move), suggest the |
| 12 | +**cost-centre (analytic) distribution** -- the `{ "<analytic_account_id_csv>": percentage }` JSON -- |
| 13 | +that this line should carry, given its context (product, product category, partner, partner |
| 14 | +categories, account code prefix, company), and *which root plans are still unallocated*. This is the |
| 15 | +inductive "lines like this combination have historically used distribution X" decision. Output is a |
| 16 | +suggested `analytic_distribution` map with NARS `(frequency, confidence)`; woa-rs writes it only as a |
| 17 | +default the user can override (the odoo compute uses `... or line.analytic_distribution`, never |
| 18 | +forcing). |
| 19 | + |
| 20 | +## Deterministic guard (AXIS-A -- stays in woa-rs) |
| 21 | +The argument assembly, the `frozendict` per-arguments cache, the `display_type=='product'` / |
| 22 | +non-invoice guard, and the `related | model` dict-merge with `or existing` fallback are deterministic |
| 23 | +(`L10-ANALYTIC.md:298-357` R7 AXIS-A part; `account_move_line.py:L1217-1248`). Per-plan |
| 24 | +100%-sum validation at post (`_validate_analytic_distribution`, `L10-ANALYTIC.md:185-225` R4) and the |
| 25 | +archived-account block (R12) are deterministic guards wrapping the suggestion. |
| 26 | + |
| 27 | +## Slot 1 -- Evidence (Arrow EvidenceRef) |
| 28 | +Two tables. The *query line context* `EvidenceRef { table: "account_move_line.analytic_context", schema_fingerprint, rows }` |
| 29 | +(one row = the line needing a suggestion): |
| 30 | + |
| 31 | +| column | dtype | signal | |
| 32 | +|---|---|---| |
| 33 | +| `move_line_id` | `Int64` | the line identity | |
| 34 | +| `product_id` | `Int64` | primary match axis (distribution-model criterion) | |
| 35 | +| `product_categ_id` | `Int64` | category fallback axis (model `_get_score` +1) | |
| 36 | +| `partner_id` | `Int64` | partner match axis | |
| 37 | +| `partner_category_ids` | `List<Int64>` | partner-tag match axis (`category_id.ids`) | |
| 38 | +| `account_code` | `Utf8` | `account_id.code` -- prefix-match axis (model `account_prefix` startswith) | |
| 39 | +| `company_id` | `Int64` | company scoping | |
| 40 | +| `display_type` | `Utf8` | guard echo (`product` vs structural) | |
| 41 | +| `related_root_plan_ids` | `List<Int64>` | plans already allocated by `_related_analytic_distribution` (SO/PO carry-over) -> the reasoner must NOT re-suggest these | |
| 42 | + |
| 43 | +The *candidate corpus* `EvidenceRef { table: "account_analytic_distribution_model", ... }` (the rules to induce over): |
| 44 | + |
| 45 | +| column | dtype | signal | |
| 46 | +|---|---|---| |
| 47 | +| `model_id` | `Int64` | rule identity | |
| 48 | +| `analytic_distribution` | `Utf8` (JSON) | the candidate distribution this rule would apply | |
| 49 | +| `sequence` | `Int32` | priority (lower wins; greedy plan-fill order) | |
| 50 | +| `partner_id` | `Int64`/nullable | rule's partner constraint (NULL = unconstrained) | |
| 51 | +| `product_id` | `Int64`/nullable | rule's product constraint | |
| 52 | +| `product_categ_id` | `Int64`/nullable | rule's category constraint | |
| 53 | +| `account_prefix` | `Utf8`/nullable | rule's account-prefix constraint (`;`/`,` split, startswith) | |
| 54 | +| `company_id` | `Int64`/nullable | rule's company constraint | |
| 55 | + |
| 56 | +## Slot 2 -- Odoo field -> signal map (cite L-doc file:lines) |
| 57 | +- `_compute_analytic_distribution` reactive compute + merge + `or existing` fallback <- `L10-ANALYTIC.md:298-357` (R7; `account_move_line.py:L1217-1248`). |
| 58 | +- `_get_analytic_distribution_arguments` dict (product_id, product_categ_id, partner_id, partner_category_id, account_prefix, company_id, related_root_plan_ids) <- `L10-ANALYTIC.md:321-331` (R7; `account_move_line.py` arg-assembly region). |
| 59 | +- candidate model fields + `_get_applicable_models` prefix filter + `_get_score` (+1 per matching criterion) + greedy "first model wins per plan" <- `L10-ANALYTIC.md:368-419` (R8; `account_analytic_distribution_model.py:L34-48`, tests test_model_score/test_model_sequence). |
| 60 | +- `analytic_distribution` JSON shape (`{ "<csv-of-account-ids>": pct }`, cross-plan keys) <- `L10-ANALYTIC.md:48-77` (R1; `account_move_line.py:L418-420`). |
| 61 | +- per-plan 100% rule (NOT global sum) + skipped account types + `validate_analytic` gating <- `L10-ANALYTIC.md:185-225` (R4; `account_move_line.py:L3146-3177, L2011-2034`). |
| 62 | +- delegation tuple `(NextBestAction, Induction, NarsTruth, Analytical)` + savant seed line <- `L10-ANALYTIC.md:358-364` (R7 AXIS-B). |
| 63 | + |
| 64 | +## Slot 3 -- Property-level alignment |
| 65 | +Decision stays **within family 0x62 SMBAccounting** on the line side, but reaches the |
| 66 | +**ontology-unmapped** `account.analytic.distribution.model` (family `None`, |
| 67 | +`L10-ANALYTIC.md:41`) and `account.analytic.account` (mapped to `fibo:Account` cost sub-type, 0x62, |
| 68 | +`L10-ANALYTIC.md:37`). No FIBO/SKR/ZUGFeRD seam is crossed for the suggestion itself -- the |
| 69 | +distribution-model class needs a **Layer-2 alignment axiom** (lance-graph follow-on, flagged in |
| 70 | +SAVANTS.md "Unmapped (None) classes"). Property-level alignment is **N/A today** (no axiom exists); |
| 71 | +when the distribution-model class is aligned, the traversed relation would be |
| 72 | +`odoo:analytic_distribution -> <cost-allocation property>` -- PROPOSED, not present. |
| 73 | +NEEDS-INPUT: the Layer-2 family + alignment axiom for `account.analytic.distribution.model` (and the |
| 74 | +shared sibling `AnalyticModelScorer` id 5) -- this is lance-graph-side work, not sourceable from L10. |
| 75 | + |
| 76 | +## Slot 4 -- AXIS-B decision in evidence terms |
| 77 | +Let E = the line-context row + the candidate distribution-model corpus (slot 1), minus models whose |
| 78 | +plans are in `related_root_plan_ids`. |
| 79 | + |
| 80 | +-> Conclusion C = `SuggestDistribution(move_line_id, { csv_key: pct, ... })` emitted with NARS |
| 81 | +`(frequency, confidence)` where: |
| 82 | +- candidate models are ranked by match strength (partner / product / product_categ / account_prefix / |
| 83 | + company agreement -- the `_get_score` evidence). Multiple matching rules **fuse** under NarsTruth |
| 84 | + rather than a single hard winner: agreement across several rules on the same plan raises frequency. |
| 85 | +- **frequency** of a proposed `(plan -> account, pct)` assignment rises with the number and |
| 86 | + specificity of matching criteria (product+categ+prefix beats company-only) and with historical |
| 87 | + co-occurrence of that distribution for similar lines. |
| 88 | +- **confidence** is the NARS weight from the count of corroborating rules/observations; a single |
| 89 | + weakly-matching model yields low confidence even at frequency 1.0. Capped by phi-1. |
| 90 | +- the greedy "first-filled plan wins" stays AXIS-A; the savant only ranks/weights the candidates the |
| 91 | + guard then applies plan-by-plan, and never re-suggests an already-`related` plan. |
| 92 | + |
| 93 | +Discriminating features (ranked): product_id match >> product_categ_id match > account_code prefix |
| 94 | +match > partner_id / partner_category match > company match. Sibling note: id 5 `AnalyticModelScorer` |
| 95 | +(CustomerCategory/Deduction/HammingMin) does the *deductive priority-scored single-winner* selection |
| 96 | +once weights are fixed; this savant does the *inductive multi-rule fusion* that proposes the |
| 97 | +distribution before scoring -- they feed different Reasoner impls (NextBestAction vs CustomerCategory). |
| 98 | + |
| 99 | +## Parity / GoBD notes |
| 100 | +Analytic distribution is Kostenrechnung (cost accounting), **not** part of the GoBD double-entry |
| 101 | +ledger -- analytic lines are a parallel, non-financial dimension (L10 R3: sign is `-balance` in |
| 102 | +company currency, a derived view). The suggestion is suggestion-only (Iron Rule 7): the per-plan |
| 103 | +100%-mandatory validation (R4) and archived-account block (R12) remain hard woa-rs guards that the |
| 104 | +suggestion must satisfy before any post. Draft lines never persist analytic lines (R5), so the |
| 105 | +suggestion materialises only at/after posting. |
0 commit comments