axis-synome 0.1.0.dev29__tar.gz → 0.1.0.dev30__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/PKG-INFO +1 -1
  2. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/_version.py +2 -2
  3. axis_synome-0.1.0.dev30/src/axis_synome/spec/suraf/README.md +281 -0
  4. axis_synome-0.1.0.dev30/src/axis_synome/spec/suraf/entities/assessor_score.py +43 -0
  5. axis_synome-0.1.0.dev30/src/axis_synome/spec/suraf/entities/assets.py +48 -0
  6. axis_synome-0.1.0.dev30/src/axis_synome/spec/suraf/entities/mappings.py +60 -0
  7. axis_synome-0.1.0.dev30/src/axis_synome/spec/suraf/formulas/crr.py +54 -0
  8. axis_synome-0.1.0.dev30/src/axis_synome/spec/suraf/formulas/scoring.py +86 -0
  9. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome.egg-info/PKG-INFO +1 -1
  10. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome.egg-info/SOURCES.txt +22 -1
  11. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/__init__.py +0 -0
  12. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/entities/__init__.py +0 -0
  13. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/entities/test_assessor_score.py +33 -0
  14. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/formulas/__init__.py +0 -0
  15. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/formulas/test_crr.py +132 -0
  16. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/formulas/test_scoring.py +227 -0
  17. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/static/aave_ausdc/v1/crr_mapping.csv +18 -0
  18. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/static/aave_ausdc/v1/penalty.csv +4 -0
  19. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/static/aave_ausdc/v1/scorecards/Assessor_1_scores.csv +54 -0
  20. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/static/aave_ausdc/v1/scorecards/Assessor_2_scores.csv +54 -0
  21. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/static/aave_ausdc/v1/scorecards/Assessor_3_scores.csv +54 -0
  22. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/static/aave_ausdc/v1/weights.csv +54 -0
  23. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/suraf_client/__init__.py +0 -0
  24. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/suraf_client/suraf_client.py +256 -0
  25. axis_synome-0.1.0.dev30/tests/axis_synome/suraf/suraf_client/test_suraf_client.py +186 -0
  26. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/.flake8 +0 -0
  27. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/README.md +0 -0
  28. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/WRITING_SPECS.md +0 -0
  29. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/pyproject.toml +0 -0
  30. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/setup.cfg +0 -0
  31. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/__init__.py +0 -0
  32. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/__init__.py +0 -0
  33. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/README.md +0 -0
  34. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/entities/__init__.py +0 -0
  35. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/entities/assets.py +0 -0
  36. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/entities/assets_by_prime.py +0 -0
  37. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/entities/networks.py +0 -0
  38. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/entities/primes.py +0 -0
  39. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/entities/protocol_sets.py +0 -0
  40. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/entities/tokens.py +0 -0
  41. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/entities/types.py +0 -0
  42. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/formulas/asc.py +0 -0
  43. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/formulas/asc_collateral_ratio.py +0 -0
  44. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/formulas/asc_incentive.py +0 -0
  45. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/formulas/dab.py +0 -0
  46. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/formulas/latent_asc.py +0 -0
  47. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/formulas/ratio_latent_asc.py +0 -0
  48. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/asc/formulas/resting_asc.py +0 -0
  49. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/codegen_test/entities/agents.py +0 -0
  50. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/crypto_lending/__init__.py +0 -0
  51. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/crypto_lending/formulas/__init__.py +0 -0
  52. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/crypto_lending/formulas/lif.py +0 -0
  53. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/risk_capital/__init__.py +0 -0
  54. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/risk_capital/formulas/__init__.py +0 -0
  55. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec/risk_capital/formulas/required_risk_capital.py +0 -0
  56. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_support/__init__.py +0 -0
  57. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_support/evm_address.py +0 -0
  58. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_support/runtime/__init__.py +0 -0
  59. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_support/runtime/base.py +0 -0
  60. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_support/runtime/math.py +0 -0
  61. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_support/runtime/reference.py +0 -0
  62. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_support/validated_dataclass.py +0 -0
  63. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_support/validated_str.py +0 -0
  64. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_validator/__init__.py +0 -0
  65. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_validator/checker.py +0 -0
  66. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_validator/flake8_plugin.py +0 -0
  67. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome/spec_validator/python_subset.py +0 -0
  68. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome.egg-info/dependency_links.txt +0 -0
  69. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome.egg-info/entry_points.txt +0 -0
  70. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome.egg-info/requires.txt +0 -0
  71. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/src/axis_synome.egg-info/top_level.txt +0 -0
  72. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/__init__.py +0 -0
  73. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/conftest.py +0 -0
  74. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/mocks.py +0 -0
  75. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/test_asc.py +0 -0
  76. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/test_asc_collateral_ratio.py +0 -0
  77. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/test_asc_incentive.py +0 -0
  78. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/test_dab.py +0 -0
  79. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/test_evm_address.py +0 -0
  80. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/test_latent_asc.py +0 -0
  81. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/test_prime_agent_data_validation.py +0 -0
  82. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/test_ratio_latent_asc.py +0 -0
  83. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/asc/test_resting_asc.py +0 -0
  84. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/risk_capital/__init__.py +0 -0
  85. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/risk_capital/formulas/__init__.py +0 -0
  86. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/risk_capital/formulas/test_loss_given_default.py +0 -0
  87. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/spec_support/__init__.py +0 -0
  88. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/spec_support/runtime/__init__.py +0 -0
  89. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/spec_support/runtime/test_base.py +0 -0
  90. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/spec_support/runtime/test_math.py +0 -0
  91. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/spec_validator/test_checker.py +0 -0
  92. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/tests/axis_synome/spec_validator/test_flake8_plugin.py +0 -0
  93. {axis_synome-0.1.0.dev29 → axis_synome-0.1.0.dev30}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axis-synome
3
- Version: 0.1.0.dev29
3
+ Version: 0.1.0.dev30
4
4
  Summary: Axis specification modules (entities, formulas, validators)
5
5
  Author-email: Archon Tech <hello@archontech.ai>
6
6
  License: MIT
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.0.dev29'
22
- __version_tuple__ = version_tuple = (0, 1, 0, 'dev29')
21
+ __version__ = version = '0.1.0.dev30'
22
+ __version_tuple__ = version_tuple = (0, 1, 0, 'dev30')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,281 @@
1
+ # SURAF Specification
2
+
3
+ This module is the executable specification for Sky Protocol's **SURAF** collateral risk assessment framework. It encodes the scoring pipeline and Capital Requirement Ratio (CRR) derivation rules into typed Python so that assessments can be evaluated, tested, and audited against real scorecard data.
4
+
5
+ ---
6
+
7
+ ## Background
8
+
9
+ Sky Protocol accepts collateral assets to back USDS issuance. The risk of each asset must be quantified before capital can be committed against it. **SURAF** is the framework that does this: three independent assessors score an asset across five pillars of risk, and the resulting aggregate score determines a **Capital Requirement Ratio (CRR)** — the fraction of exposure that must be held as reserve capital.
10
+
11
+ A higher CRR means the asset is riskier and demands more capital. A penalty is added to the base CRR when assessors flag structurally weak subsections with the lowest possible score (1 out of 5).
12
+
13
+ This specification answers: *given three completed scorecards and the governance-approved mapping tables, what is the adjusted CRR for this asset?*
14
+
15
+ ---
16
+
17
+ ## Module Layout
18
+
19
+ ```
20
+ entities/ : data types and constants (inputs to the pipeline)
21
+ formulas/ : pure functions that compute the CRR from entities
22
+ opaque/ : implementation helpers exempt from the spec subset (e.g. interpolation via numpy)
23
+ ```
24
+
25
+ All formulas follow one rule: **they accept raw entity inputs and compute everything internally**. No formula accepts a precomputed intermediate value as an argument.
26
+
27
+ ---
28
+
29
+ ## Entities
30
+
31
+ ### `AssessorScore` (`entities/assessor_score.py`)
32
+
33
+ One assessor's complete evaluation of an asset. Subsection scores are grouped by pillar and stored together with their absolute weights.
34
+
35
+ | Field | Type | Description |
36
+ |---|---|---|
37
+ | `scores` | `list[PillarEntry]` | Scored pillars; each entry is `(pillar_weight, [(section_weight, score), ...])` |
38
+
39
+ Where `PillarEntry = tuple[PillarWeight, list[SectionEntry]]` and `SectionEntry = tuple[SubsectionWeight, SubsectionScore]`.
40
+
41
+ **Score range:** `SubsectionScore` ∈ [`1.0`, `5.0`].
42
+
43
+ **Weight semantics:** weights are **absolute integers** defined in the weights file, e.g. `40` for Pillar I and `8` for subsection 1.A. Normalisation (dividing by totals) happens inside the formula functions, not in the inputs.
44
+
45
+ Only scored subsections are included; callers must filter out blanks before construction.
46
+
47
+ ---
48
+
49
+ ### `EligibleSURAFAsset` (`entities/assets.py`)
50
+
51
+ A closed-world Enum of assets approved for SURAF assessment. Each member's `.value` is a fully specified `AssetWrapper` (token, network, protocol, on-chain address, Atlas source reference).
52
+
53
+ | Member | Asset |
54
+ |---|---|
55
+ | `AAVE_AUSDC` | Aave aUSDC on Ethereum Mainnet via Aave Core V3 |
56
+
57
+ ---
58
+
59
+ ### `CRRMapping` (`entities/mappings.py`)
60
+
61
+ Governance-approved score-to-CRR% interpolation table. Maps an aggregate score (1–5) to an unadjusted Capital Requirement Ratio (%). Linear interpolation between breakpoints; values outside the range are clamped.
62
+
63
+ | Field | Type | Description |
64
+ |---|---|---|
65
+ | `table` | `tuple[tuple[float, float], ...]` | Ordered `(score, crr_pct)` breakpoints; scores strictly increasing |
66
+
67
+ Example from the reference table:
68
+
69
+ | Score | CRR% |
70
+ |---|---|
71
+ | 1.00 | 100 |
72
+ | 2.50 | 28 |
73
+ | 3.00 | 20 |
74
+ | 5.00 | 5 |
75
+
76
+ ---
77
+
78
+ ### `PenaltyMapping` (`entities/mappings.py`)
79
+
80
+ Governance-approved count-to-penalty interpolation table. Maps the total number of score-1 subsections across all assessors to a penalty in percentage points added to the base CRR.
81
+
82
+ | Field | Type | Description |
83
+ |---|---|---|
84
+ | `table` | `tuple[tuple[int, float], ...]` | Ordered `(n_score_1, penalty_pp)` breakpoints; counts non-negative and strictly increasing |
85
+
86
+ Example: 7 subsections scored 1 → `penalty_pp = 10 + (7 − 5) / (10 − 5) × (20 − 10) = 14.0 pp`.
87
+
88
+ ---
89
+
90
+ ## Formulas
91
+
92
+ ### Top-level: `crr` (`formulas/crr.py`)
93
+
94
+ ```python
95
+ crr(asset, assessor_scores, crr_mapping, penalty_mapping) -> float
96
+ ```
97
+
98
+ Computes the adjusted Capital Requirement Ratio for a SURAF asset. Returns a value in `[0, 100]`, capped at 100%.
99
+
100
+ **Pipeline:**
101
+
102
+ ```
103
+ 1. overall_score(assessor) per-assessor two-level weighted average (section → pillar → overall)
104
+ 2. weighted_average(assessors) arithmetic mean of all assessors' overall scores → avg_score
105
+ 3. map_crr(avg_score, ...) look up base CRR% by linear interpolation
106
+ 4. total_n_score_1(assessors) count of all score-1 subsections across all assessors
107
+ 5. calculate_penalty(n, ...) look up penalty pp by linear interpolation
108
+ 6. min(base_crr + penalty, 100) final adjusted CRR, capped at 100%
109
+ ```
110
+
111
+ ---
112
+
113
+ ### Scoring helpers (`formulas/scoring.py`)
114
+
115
+ These pure functions implement each stage of the pipeline. They are composed by `crr`.
116
+
117
+ #### `overall_score(assessor)` → `float`
118
+
119
+ Two-level weighted average for a single assessor.
120
+
121
+ ```
122
+ pillar_score_i = Σ(section_weight_j × score_j) / Σ(section_weight_j)
123
+ overall = Σ(pillar_score_i × pillar_weight_i) / Σ(pillar_weight_i)
124
+ ```
125
+
126
+ ---
127
+
128
+ #### `weighted_average(assessors)` → `float`
129
+
130
+ Arithmetic mean of `overall_score` across all assessors.
131
+
132
+ ---
133
+
134
+ #### `n_score_1(assessor)` → `int`
135
+
136
+ Count of subsections where an assessor gave the minimum score of `1`. Used to measure how many structurally weak areas the assessor flagged.
137
+
138
+ ---
139
+
140
+ #### `total_n_score_1(assessors)` → `int`
141
+
142
+ Sum of `n_score_1` across all assessors. This is the input to the penalty interpolation.
143
+
144
+ ---
145
+
146
+
147
+ #### `map_crr(avg_score, crr_mapping)` → `float`
148
+
149
+ Interpolates the base CRR% from the aggregate score using the `CRRMapping` table.
150
+
151
+ ---
152
+
153
+ #### `calculate_penalty(n_score1, penalty_mapping)` → `float`
154
+
155
+ Interpolates the penalty in percentage points from the score-1 count using the `PenaltyMapping` table.
156
+
157
+ ---
158
+
159
+ ## Loading Data from CSVs
160
+
161
+ The spec operates on fully-constructed dataclasses. To go from raw CSV files (assessor scorecards, weights, mapping tables) to spec inputs, use the loader classes in `tests/axis_synome/suraf/suraf_client/suraf_client.py`.
162
+
163
+ > **Note:** these loaders are test utilities; they live in `tests/` because CSV I/O is a runtime concern, not a spec concern. They are the reference implementation for how data should be adapted before being passed to spec formulas.
164
+
165
+ ---
166
+
167
+ ### `AssessorScoreInput.from_csv(path, weights_path)`
168
+
169
+ Parses an assessor scorecard CSV together with the weights CSV and returns an `AssessorScore` with absolute weights.
170
+
171
+ **Scorecard CSV** (`scorecards/Assessor_N_scores.csv`) — required columns: `pillar`, `subsection_ref`, `score`:
172
+
173
+ ```
174
+ pillar,subsection_ref,title,score
175
+ ,,SECTION A: GENERAL INFORMATION,
176
+ 1,1.A,Asset Composition & Eligibility,4
177
+ 1,1.B,Asset Cashflow Structure,3
178
+ 1,1.C,Asset Liquidity Analysis,
179
+ 2,2.A,Recoverability & Custody,5
180
+ ```
181
+
182
+ Rows with a blank `score` column are skipped. Rows where `pillar` is not a digit (e.g. section headers) are skipped.
183
+
184
+ **Weights CSV** (`weights.csv`) — required columns: `pillar`, `subsection_ref`, `title`, `pillar_weight`, `subsection_weight`:
185
+
186
+ ```
187
+ pillar,subsection_ref,title,pillar_weight,subsection_weight
188
+ ,,PILLAR I: ASSET & COLLATERAL ASSESSMENT,40,0
189
+ 1,1.A,Asset Composition & Eligibility,0,8
190
+ 1,1.B,Asset Cashflow Structure,0,7
191
+ ```
192
+
193
+ ```python
194
+ from suraf_client import AssessorScoreInput
195
+
196
+ a1 = AssessorScoreInput.from_csv("scorecards/Assessor_1_scores.csv", "weights.csv")
197
+ a2 = AssessorScoreInput.from_csv("scorecards/Assessor_2_scores.csv", "weights.csv")
198
+ a3 = AssessorScoreInput.from_csv("scorecards/Assessor_3_scores.csv", "weights.csv")
199
+ ```
200
+
201
+ ---
202
+
203
+ ### `CRRMappingInput.from_csv(path)`
204
+
205
+ Parses a CRR interpolation table CSV and returns a `CRRMapping`.
206
+
207
+ **CSV** (`crr_mapping.csv`) — required columns: `score`, `crr`:
208
+
209
+ ```
210
+ score,crr
211
+ 1.00,100
212
+ 2.50,28
213
+ 5.00,5
214
+ ```
215
+
216
+ Scores must be strictly increasing. At least two breakpoints are required.
217
+
218
+ ```python
219
+ from suraf_client import CRRMappingInput
220
+
221
+ crr_mapping = CRRMappingInput.from_csv("crr_mapping.csv")
222
+ ```
223
+
224
+ ---
225
+
226
+ ### `PenaltyMappingInput.from_csv(path)`
227
+
228
+ Parses a penalty interpolation table CSV and returns a `PenaltyMapping`.
229
+
230
+ **CSV** (`penalty.csv`) — required columns: `n_score_1`, `penalty_pp`:
231
+
232
+ ```
233
+ n_score_1,penalty_pp
234
+ 0,0
235
+ 2,0
236
+ 20,30
237
+ ```
238
+
239
+ `n_score_1` values must be non-negative and strictly increasing.
240
+
241
+ ```python
242
+ from suraf_client import PenaltyMappingInput
243
+
244
+ penalty_mapping = PenaltyMappingInput.from_csv("penalty.csv")
245
+ ```
246
+
247
+ ---
248
+
249
+ ### End-to-end example
250
+
251
+ ```python
252
+ from suraf_client import AssessorScoreInput, CRRMappingInput, PenaltyMappingInput
253
+ from axis_synome.spec.suraf.entities.assets import EligibleSURAFAsset
254
+ from axis_synome.spec.suraf.formulas.crr import crr
255
+
256
+ base = "tests/axis_synome/suraf/static/aave_ausdc/v1"
257
+
258
+ assessors = [
259
+ AssessorScoreInput.from_csv(f"{base}/scorecards/Assessor_{i}_scores.csv", f"{base}/weights.csv")
260
+ for i in range(1, 4)
261
+ ]
262
+ crr_mapping = CRRMappingInput.from_csv(f"{base}/crr_mapping.csv")
263
+ penalty_map = PenaltyMappingInput.from_csv(f"{base}/penalty.csv")
264
+
265
+ result = crr(EligibleSURAFAsset.AAVE_AUSDC_DEMO, assessors, crr_mapping, penalty_map)
266
+ print(f"Adjusted CRR: {result:.2f}%")
267
+ ```
268
+
269
+ ---
270
+
271
+ ## Design Principles
272
+
273
+ 1. **No precomputed inputs.** Every formula accepts raw entities and derives all intermediates internally. This makes formulas independently testable and prevents stale-input bugs.
274
+
275
+ 2. **Absolute weights, normalised internally.** Weights (e.g. `40` for Pillar I, `8` for subsection 1.A) are stored as absolute integers on `AssessorScore`. Each formula divides by the sum of weights at its level — pillar normalisation inside `overall_score`, assessor averaging inside `weighted_average`.
276
+
277
+ 3. **Governance tables are entity inputs.** `CRRMapping` and `PenaltyMapping` are dataclass inputs, not hardcoded in formulas. Updating the governance decision means providing a new table, not changing spec code.
278
+
279
+ 4. **Score-1 penalty is additive.** The penalty for flagged subsections (score = 1) is added to the base CRR after interpolation and the total is capped at `100%`. This is a separate governance lever from the score-to-CRR curve.
280
+
281
+ 5. **Opaque functions are isolated.** Numerical primitives that require external libraries (e.g. `numpy.interp` for linear interpolation) live in `opaque/` and are exempt from the spec language subset. The spec formulas call them by name; the parser treats the call as an uninterpreted function.
@@ -0,0 +1,43 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import Field
4
+
5
+ from axis_synome.spec_support.validated_dataclass import validated_dataclass
6
+
7
+ SCORE_MIN: int = 1
8
+ SCORE_MAX: int = 5
9
+
10
+ SubsectionScore = Annotated[int, Field(ge=SCORE_MIN, le=SCORE_MAX)]
11
+ SubsectionWeight = Annotated[int, Field(gt=0)]
12
+ PillarWeight = Annotated[int, Field(gt=0)]
13
+
14
+ SectionEntry = tuple[SubsectionWeight, SubsectionScore]
15
+ PillarEntry = tuple[PillarWeight, list[SectionEntry]]
16
+
17
+
18
+ @validated_dataclass
19
+ class AssessorScore:
20
+ """One assessor's subsection-level evaluation of a SURAF asset.
21
+
22
+ ``scores`` groups subsection evaluations by pillar. Each entry is a
23
+ ``(pillar_weight, sections)`` pair where:
24
+
25
+ * ``pillar_weight`` – absolute weight of the pillar (e.g. 40 for Pillar I).
26
+ * ``sections`` – list of ``(section_weight, score)`` pairs for every
27
+ scored subsection within that pillar, where
28
+ ``section_weight`` is the absolute subsection weight
29
+ (e.g. 8) and ``score`` ∈ [``SCORE_MIN``, ``SCORE_MAX``].
30
+
31
+ Only scored (non-null) subsections are included; callers must filter out
32
+ unscored subsections before constructing this object.
33
+
34
+ The two-level weighted average for this assessor is::
35
+
36
+ pillar_score_i = Σ(score_j × section_w_j) / Σ(section_w_j)
37
+ overall = Σ(pillar_score_i × pillar_w_i) / Σ(pillar_w_i)
38
+
39
+ Attributes:
40
+ scores: Absolute-weight-annotated subsection evaluations grouped by pillar.
41
+ """
42
+
43
+ scores: list[PillarEntry]
@@ -0,0 +1,48 @@
1
+ from dataclasses import KW_ONLY
2
+ from enum import Enum
3
+
4
+ from axis_synome.spec.asc.entities.assets import Asset
5
+ from axis_synome.spec.asc.entities.networks import Network
6
+ from axis_synome.spec.asc.entities.protocol_sets import Protocol
7
+ from axis_synome.spec.asc.entities.tokens import Token
8
+ from axis_synome.spec_support.evm_address import EvmAddress
9
+ from axis_synome.spec_support.validated_dataclass import validated_dataclass
10
+
11
+
12
+ @validated_dataclass
13
+ class AssetWrapper(Asset):
14
+ """Wrapper around Asset to include SURAF-specific fields (i.e halo identifiers, prime's link to the asset, etc).
15
+ Placeholder until we have a more complete SURAF asset spec.
16
+
17
+ TODO: Investigate if there are shareable entities between different specs (e.g. Asset) and move to a common module
18
+ to avoid duplication. For now, we can keep SURAF-specific fields in a wrapper class that inherit from asc/assets
19
+ until we have a more complete SURAF asset spec.
20
+
21
+ """
22
+
23
+ _: KW_ONLY
24
+ halo_id: str | None = None
25
+
26
+
27
+ class SURAFAsset(Enum):
28
+ """All SURAF assets."""
29
+
30
+ AAVE_AUSDC_DEMO = AssetWrapper(
31
+ token=Token.A_ETH_USDC,
32
+ network=Network.ETHEREUM_MAINNET,
33
+ protocol=Protocol.AAVE_CORE_V3,
34
+ address=EvmAddress("0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c"),
35
+ # TODO: include url/ uuid
36
+ source="A.6.1.1.2.2.6.1.3.1.5.1.2.2.1",
37
+ underlying_asset=Token.A_ETH_USDC,
38
+ underlying_asset_address=EvmAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
39
+ underlying_asset_source="A.6.1.1.2.2.6.1.3.1.5.1.2.2.2",
40
+ )
41
+
42
+
43
+ class EligibleSURAFAsset(Enum):
44
+ """SURAF assets eligible for CRR calculation.
45
+ We are only supporting a demo asset for now.
46
+ """
47
+
48
+ AAVE_AUSDC_DEMO = SURAFAsset.AAVE_AUSDC_DEMO.value
@@ -0,0 +1,60 @@
1
+ from axis_synome.spec_support.validated_dataclass import validated_dataclass
2
+
3
+
4
+ @validated_dataclass
5
+ class CRRMapping:
6
+ """Score → CRR% interpolation table.
7
+
8
+ Maps an aggregate assessor score (1–5) to an unadjusted Capital
9
+ Requirement Ratio (%). Interpolation is linear between breakpoints;
10
+ values outside the range are clamped to the first/last CRR value.
11
+
12
+ Attributes:
13
+ table: Ordered breakpoints as ``((score, crr_pct), ...)``.
14
+ Scores must be strictly increasing; CRR values are in percent.
15
+
16
+ Example::
17
+
18
+ CRRMapping(
19
+ table=(
20
+ (1.0, 100.0),
21
+ (2.5, 60.0),
22
+ (4.0, 20.0),
23
+ (5.0, 5.0),
24
+ )
25
+ )
26
+ # avg_score=3.25 interpolates between (2.5, 60) and (4.0, 20):
27
+ # crr = 60 + (3.25 - 2.5) / (4.0 - 2.5) * (20 - 60) = 40.0 %
28
+ """
29
+
30
+ table: tuple[tuple[float, float], ...]
31
+
32
+
33
+ @validated_dataclass
34
+ class PenaltyMapping:
35
+ """Score-1 count → penalty percentage-point interpolation table.
36
+
37
+ Maps the total number of score-1 subsections across all assessors to a
38
+ penalty in percentage points added to the unadjusted CRR. More score-1
39
+ subsections → higher penalty → higher capital charge.
40
+
41
+ Attributes:
42
+ table: Ordered breakpoints as ``((n_score_1, penalty_pp), ...)``.
43
+ ``n_score_1`` values must be non-negative and strictly
44
+ increasing.
45
+
46
+ Example::
47
+
48
+ PenaltyMapping(
49
+ table=(
50
+ ( 0, 0.0),
51
+ ( 5, 10.0),
52
+ (10, 20.0),
53
+ (20, 30.0),
54
+ )
55
+ )
56
+ # 7 total score-1 subsections interpolates between (5, 10) and (10, 20):
57
+ # penalty_pp = 10 + (7 - 5) / (10 - 5) * (20 - 10) = 14.0 pp
58
+ """
59
+
60
+ table: tuple[tuple[int, float], ...]
@@ -0,0 +1,54 @@
1
+ from axis_synome.spec.suraf.entities.assessor_score import AssessorScore
2
+ from axis_synome.spec.suraf.entities.assets import EligibleSURAFAsset
3
+ from axis_synome.spec.suraf.entities.mappings import CRRMapping, PenaltyMapping
4
+ from axis_synome.spec.suraf.formulas.scoring import (
5
+ calculate_penalty,
6
+ map_crr,
7
+ total_n_score_1,
8
+ weighted_average,
9
+ )
10
+
11
+
12
+ def crr(
13
+ _asset: EligibleSURAFAsset,
14
+ assessor_scores: list[AssessorScore],
15
+ crr_mapping: CRRMapping,
16
+ penalty_mapping: PenaltyMapping,
17
+ ) -> float:
18
+ """Compute the adjusted Capital Requirement Ratio (CRR) for a SURAF asset
19
+ :source_uuid: 3828778e-0197-4ce9-a836-6770d04f2ea9
20
+
21
+ The CRR reflects the credit and operational risk of the collateral asset and
22
+ determines the required reserve capital fraction for a given USD exposure.
23
+
24
+ Pipeline
25
+ --------
26
+ 1. Each assessor's overall score is a two-level weighted average: subsection
27
+ scores are weighted by their absolute section weights within each pillar,
28
+ then pillar scores are weighted by their absolute pillar weights.
29
+ Weights are absolute integers (e.g. 40 for Pillar I, 8 for subsection 1.A);
30
+ normalisation is done internally by ``overall_score``.
31
+ 2. The assessors' overall scores are averaged to produce ``avg_score``.
32
+ 3. ``avg_score`` is mapped to an unadjusted CRR via ``crr_mapping``
33
+ (linear interpolation).
34
+ 4. A penalty in percentage points is derived from the total number of
35
+ score-1 subsections across all assessors via ``penalty_mapping``
36
+ (linear interpolation).
37
+ 5. ``adjusted_crr = min(unadjusted_crr + penalty_pp, 100.0)``.
38
+
39
+ Args:
40
+ _asset: The eligible SURAF asset being assessed. Placeholder to allow for future asset-specific logic (i.e specific mappings defined in axis-synome not in client side); currently unused.
41
+ assessor_scores: One :class:`AssessorScore` per independent assessor
42
+ (typically three). Blank subsections must already be
43
+ filtered out before constructing each instance.
44
+ crr_mapping: Score → CRR% interpolation table.
45
+ penalty_mapping: n_score_1 → penalty pp interpolation table.
46
+
47
+ Returns:
48
+ Adjusted CRR as a percentage in ``[0, 100]``.
49
+ """
50
+ return min(
51
+ map_crr(weighted_average(assessor_scores), crr_mapping)
52
+ + calculate_penalty(total_n_score_1(assessor_scores), penalty_mapping),
53
+ 100.0,
54
+ )
@@ -0,0 +1,86 @@
1
+ """SURAF scoring pipeline as pure functions.
2
+
3
+ Scoring hierarchy
4
+ -----------------
5
+ 1. Subsection level – AssessorScore.scores is a list of (pillar_weight, sections)
6
+ pairs where sections = [(section_weight, score), ...].
7
+ All weights are absolute (e.g. 40 for Pillar I, 8 for 1.A).
8
+ 2. Overall score – two-level weighted average:
9
+ pillar_score_i = Σ(section_weight_j * score_j) / Σ(section_weight_j)
10
+ overall = Σ(pillar_score_i * pillar_weight_i) / Σ(pillar_weight_i)
11
+ 3. Aggregate – simple arithmetic mean of the three assessors' overall
12
+ scores.
13
+ 4. CRR – interpolated from CRRMapping.
14
+ 5. Penalty – interpolated from PenaltyMapping; Added to CRR.
15
+ """
16
+
17
+ from axis_synome.spec.suraf.entities.assessor_score import AssessorScore, SectionEntry
18
+ from axis_synome.spec.suraf.entities.mappings import CRRMapping, PenaltyMapping
19
+ from axis_synome.spec_support.runtime import math
20
+
21
+
22
+ def section_total_weight(sections: list[SectionEntry]) -> float:
23
+ """Sum of section weights; the denominator of the pillar-level weighted average."""
24
+ return sum(section_weight for section_weight, _ in sections)
25
+
26
+
27
+ def section_weighted_score(sections: list[SectionEntry]) -> float:
28
+ """Sum of (section_weight × score) for all sections; the numerator of the pillar-level weighted average."""
29
+ return sum(section_weight * score for section_weight, score in sections)
30
+
31
+
32
+ def overall_score(assessor: AssessorScore) -> float:
33
+ """Two-level weighted average score for a single assessor.
34
+
35
+ First computes a weighted average score within each pillar
36
+ (``section_weighted_score / section_total_weight``), then weights those
37
+ pillar scores by their pillar weights. Pillars with no sections or a zero
38
+ total section weight are skipped.
39
+
40
+ Will raise ZeroDivisionError if the assessor has no scored subsections or all pillar
41
+ weights sum to zero. Should not happen in practice due to validation of the client's AssessorScore instance.
42
+ """
43
+
44
+ total_pillar_weight = sum(pillar_weight for pillar_weight, _ in assessor.scores)
45
+
46
+ pillar_total = sum(
47
+ pillar_weight * section_weighted_score(sections) / section_total_weight(sections)
48
+ for pillar_weight, sections in assessor.scores
49
+ if sections and section_total_weight(sections)
50
+ )
51
+ return pillar_total / total_pillar_weight
52
+
53
+
54
+ def n_score_1(assessor: AssessorScore) -> int:
55
+ """Count of subsections where this assessor gave the minimum score of 1.0."""
56
+ return sum(1 for _, sections in assessor.scores for _, score in sections if score == 1.0)
57
+
58
+
59
+ def total_n_score_1(assessors: list[AssessorScore]) -> int:
60
+ """Sum of score-1 subsection counts across all assessors; the input to penalty interpolation."""
61
+ return sum(n_score_1(a) for a in assessors)
62
+
63
+
64
+ def weighted_average(assessors: list[AssessorScore]) -> float:
65
+ """Arithmetic mean of overall scores across all assessors that produced a valid score."""
66
+ valid_count = sum(1 for a in assessors if overall_score(a))
67
+ valid_sum = sum(overall_score(a) for a in assessors if overall_score(a))
68
+ return valid_sum / valid_count
69
+
70
+
71
+ def map_crr(avg_score: float, crr_mapping: CRRMapping) -> float:
72
+ """Interpolate the base CRR% from the aggregate assessor score using the CRR mapping table."""
73
+ return math.linear_interp(
74
+ [avg_score],
75
+ [score for score, _ in crr_mapping.table],
76
+ [crr for _, crr in crr_mapping.table],
77
+ )[0]
78
+
79
+
80
+ def calculate_penalty(n_score1: int, penalty_mapping: PenaltyMapping) -> float:
81
+ """Interpolate the penalty in percentage points from the count of score-1 subsections."""
82
+ return math.linear_interp(
83
+ [float(n_score1)],
84
+ [n for n, _ in penalty_mapping.table],
85
+ [penalty for _, penalty in penalty_mapping.table],
86
+ )[0]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axis-synome
3
- Version: 0.1.0.dev29
3
+ Version: 0.1.0.dev30
4
4
  Summary: Axis specification modules (entities, formulas, validators)
5
5
  Author-email: Archon Tech <hello@archontech.ai>
6
6
  License: MIT
@@ -35,6 +35,12 @@ src/axis_synome/spec/crypto_lending/formulas/lif.py
35
35
  src/axis_synome/spec/risk_capital/__init__.py
36
36
  src/axis_synome/spec/risk_capital/formulas/__init__.py
37
37
  src/axis_synome/spec/risk_capital/formulas/required_risk_capital.py
38
+ src/axis_synome/spec/suraf/README.md
39
+ src/axis_synome/spec/suraf/entities/assessor_score.py
40
+ src/axis_synome/spec/suraf/entities/assets.py
41
+ src/axis_synome/spec/suraf/entities/mappings.py
42
+ src/axis_synome/spec/suraf/formulas/crr.py
43
+ src/axis_synome/spec/suraf/formulas/scoring.py
38
44
  src/axis_synome/spec_support/__init__.py
39
45
  src/axis_synome/spec_support/evm_address.py
40
46
  src/axis_synome/spec_support/validated_dataclass.py
@@ -67,4 +73,19 @@ tests/axis_synome/spec_support/runtime/__init__.py
67
73
  tests/axis_synome/spec_support/runtime/test_base.py
68
74
  tests/axis_synome/spec_support/runtime/test_math.py
69
75
  tests/axis_synome/spec_validator/test_checker.py
70
- tests/axis_synome/spec_validator/test_flake8_plugin.py
76
+ tests/axis_synome/spec_validator/test_flake8_plugin.py
77
+ tests/axis_synome/suraf/__init__.py
78
+ tests/axis_synome/suraf/entities/__init__.py
79
+ tests/axis_synome/suraf/entities/test_assessor_score.py
80
+ tests/axis_synome/suraf/formulas/__init__.py
81
+ tests/axis_synome/suraf/formulas/test_crr.py
82
+ tests/axis_synome/suraf/formulas/test_scoring.py
83
+ tests/axis_synome/suraf/static/aave_ausdc/v1/crr_mapping.csv
84
+ tests/axis_synome/suraf/static/aave_ausdc/v1/penalty.csv
85
+ tests/axis_synome/suraf/static/aave_ausdc/v1/weights.csv
86
+ tests/axis_synome/suraf/static/aave_ausdc/v1/scorecards/Assessor_1_scores.csv
87
+ tests/axis_synome/suraf/static/aave_ausdc/v1/scorecards/Assessor_2_scores.csv
88
+ tests/axis_synome/suraf/static/aave_ausdc/v1/scorecards/Assessor_3_scores.csv
89
+ tests/axis_synome/suraf/suraf_client/__init__.py
90
+ tests/axis_synome/suraf/suraf_client/suraf_client.py
91
+ tests/axis_synome/suraf/suraf_client/test_suraf_client.py
@@ -0,0 +1,33 @@
1
+ """Tests for AssessorScore entity validation."""
2
+
3
+ import pytest
4
+ from pydantic import ValidationError
5
+
6
+ from axis_synome.spec.suraf.entities.assessor_score import AssessorScore
7
+
8
+
9
+ class TestAssessorScoreValidation:
10
+ def test_boundary_scores_accepted(self):
11
+ a = AssessorScore(scores=[(10, [(5, 1), (5, 5)])])
12
+ assert len(a.scores) == 1
13
+ assert len(a.scores[0][1]) == 2
14
+
15
+ def test_score_below_min_rejected(self):
16
+ with pytest.raises(ValidationError):
17
+ AssessorScore(scores=[(10, [(5, 0)])])
18
+
19
+ def test_score_above_max_rejected(self):
20
+ with pytest.raises(ValidationError):
21
+ AssessorScore(scores=[(10, [(5, 6)])])
22
+
23
+ def test_empty_scores_list_accepted(self):
24
+ a = AssessorScore(scores=[])
25
+ assert a.scores == []
26
+
27
+ def test_empty_sections_in_pillar_accepted(self):
28
+ a = AssessorScore(scores=[(10, [])])
29
+ assert a.scores[0][1] == []
30
+
31
+ def test_large_absolute_weights_accepted(self):
32
+ a = AssessorScore(scores=[(40, [(8, 3), (12, 4)])])
33
+ assert len(a.scores) == 1