f8-engine 0.2.0__tar.gz → 0.3.0__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 (103) hide show
  1. {f8_engine-0.2.0 → f8_engine-0.3.0}/Cargo.lock +2 -2
  2. {f8_engine-0.2.0 → f8_engine-0.3.0}/Cargo.toml +1 -1
  3. {f8_engine-0.2.0 → f8_engine-0.3.0}/PKG-INFO +27 -25
  4. {f8_engine-0.2.0 → f8_engine-0.3.0}/README.md +26 -24
  5. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/eval/model.rs +1 -0
  6. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/graph/resolve.rs +199 -181
  7. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/ir/expand.rs +9 -0
  8. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/ir/symbol.rs +1 -0
  9. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/layout/error.rs +19 -0
  10. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/layout/mod.rs +245 -63
  11. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/lowering/address.rs +39 -7
  12. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/lowering/mod.rs +30 -34
  13. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/expr/ast.rs +170 -14
  14. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/expr/mod.rs +2 -1
  15. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/expr/parser.rs +114 -187
  16. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/lib.rs +1 -0
  17. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/model/mod.rs +3 -2
  18. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/model/table.rs +113 -0
  19. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/model/theme.rs +18 -7
  20. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-python/src/error.rs +11 -1
  21. {f8_engine-0.2.0 → f8_engine-0.3.0}/pyproject.toml +4 -0
  22. {f8_engine-0.2.0 → f8_engine-0.3.0}/python/f8/__init__.py +2 -1
  23. {f8_engine-0.2.0 → f8_engine-0.3.0}/python/f8/builder.py +204 -9
  24. f8_engine-0.3.0/python/f8/skills/f8-modeling/SKILL.md +45 -0
  25. f8_engine-0.3.0/python/f8/skills/f8-modeling/references/auditing-and-repair.md +170 -0
  26. f8_engine-0.3.0/python/f8/skills/f8-modeling/references/functions.md +128 -0
  27. f8_engine-0.3.0/python/f8/skills/f8-modeling/references/syntax-and-grid.md +324 -0
  28. {f8_engine-0.2.0 → f8_engine-0.3.0}/python/f8/types.py +36 -4
  29. f8_engine-0.2.0/python/f8/skills/f8-modeling/SKILL.md +0 -43
  30. f8_engine-0.2.0/python/f8/skills/f8-modeling/references/auditing-and-repair.md +0 -367
  31. f8_engine-0.2.0/python/f8/skills/f8-modeling/references/functions.md +0 -172
  32. f8_engine-0.2.0/python/f8/skills/f8-modeling/references/syntax-and-grid.md +0 -405
  33. {f8_engine-0.2.0 → f8_engine-0.3.0}/LICENSE +0 -0
  34. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/Cargo.toml +0 -0
  35. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/compiled/error.rs +0 -0
  36. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/compiled/mod.rs +0 -0
  37. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/compiled/model.rs +0 -0
  38. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/emission/error.rs +0 -0
  39. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/emission/format.rs +0 -0
  40. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/emission/mod.rs +0 -0
  41. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/emission/workbook.rs +0 -0
  42. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/error.rs +0 -0
  43. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/eval/context.rs +0 -0
  44. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/eval/error.rs +0 -0
  45. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/eval/expr.rs +0 -0
  46. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/eval/mod.rs +0 -0
  47. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/graph/cycle.rs +0 -0
  48. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/graph/dependency.rs +0 -0
  49. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/graph/error.rs +0 -0
  50. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/graph/mod.rs +0 -0
  51. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/ir/cell.rs +0 -0
  52. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/ir/error.rs +0 -0
  53. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/ir/id.rs +0 -0
  54. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/ir/mod.rs +0 -0
  55. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/layout/a1.rs +0 -0
  56. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/lowering/error.rs +0 -0
  57. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/compiler/mod.rs +0 -0
  58. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/expr/error.rs +0 -0
  59. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/common.rs +0 -0
  60. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/date/constructors.rs +0 -0
  61. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/date/mod.rs +0 -0
  62. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/date/parts.rs +0 -0
  63. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/date/serial.rs +0 -0
  64. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/date/yearfrac.rs +0 -0
  65. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/error.rs +0 -0
  66. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/financial/annuity.rs +0 -0
  67. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/financial/discounting.rs +0 -0
  68. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/financial/irr.rs +0 -0
  69. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/financial/mod.rs +0 -0
  70. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/logical/boolean.rs +0 -0
  71. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/logical/branching.rs +0 -0
  72. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/logical/error_handling.rs +0 -0
  73. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/logical/mod.rs +0 -0
  74. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/lookup/index.rs +0 -0
  75. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/lookup/mod.rs +0 -0
  76. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/lookup/search.rs +0 -0
  77. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/lookup/xlookup.rs +0 -0
  78. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/lookup/xmatch.rs +0 -0
  79. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/math/aggregation.rs +0 -0
  80. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/math/basic.rs +0 -0
  81. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/math/conditional.rs +0 -0
  82. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/math/criteria.rs +0 -0
  83. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/math/mod.rs +0 -0
  84. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/math/product.rs +0 -0
  85. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/math/rounding.rs +0 -0
  86. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/mod.rs +0 -0
  87. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/registry.rs +0 -0
  88. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/functions/traits.rs +0 -0
  89. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/model/error.rs +0 -0
  90. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/model/format.rs +0 -0
  91. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/model/id.rs +0 -0
  92. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/model/manifest.rs +0 -0
  93. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/value/error.rs +0 -0
  94. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/value/format.rs +0 -0
  95. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-core/src/value/mod.rs +0 -0
  96. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-python/Cargo.toml +0 -0
  97. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-python/src/lib.rs +0 -0
  98. {f8_engine-0.2.0 → f8_engine-0.3.0}/crates/f8-python/src/model.rs +0 -0
  99. {f8_engine-0.2.0 → f8_engine-0.3.0}/python/f8/__main__.py +0 -0
  100. {f8_engine-0.2.0 → f8_engine-0.3.0}/python/f8/_core.pyi +0 -0
  101. {f8_engine-0.2.0 → f8_engine-0.3.0}/python/f8/audit.py +0 -0
  102. {f8_engine-0.2.0 → f8_engine-0.3.0}/python/f8/cli.py +0 -0
  103. {f8_engine-0.2.0 → f8_engine-0.3.0}/python/f8/py.typed +0 -0
@@ -37,7 +37,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
37
37
 
38
38
  [[package]]
39
39
  name = "f8-core"
40
- version = "0.2.0"
40
+ version = "0.3.0"
41
41
  dependencies = [
42
42
  "indexmap",
43
43
  "petgraph",
@@ -50,7 +50,7 @@ dependencies = [
50
50
 
51
51
  [[package]]
52
52
  name = "f8-python"
53
- version = "0.2.0"
53
+ version = "0.3.0"
54
54
  dependencies = [
55
55
  "f8-core",
56
56
  "pyo3",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/f8-core", "crates/f8-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.2.0"
6
+ version = "0.3.0"
7
7
  edition = "2024"
8
8
  description = "Financial modeling engine with compiler verification and live Excel formulas"
9
9
  authors = ["Michael (Chencheng) Hu, CFA"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: f8-engine
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Financial and Insurance Industry
@@ -26,7 +26,7 @@ Project-URL: Repository, https://github.com/mhcfa/f8
26
26
 
27
27
  # f8
28
28
 
29
- Financial modeling engine with compiler verification and live Excel formulas.
29
+ Author, verify, and automate financial models with code and AI agents, compiled into live Excel workbooks.
30
30
 
31
31
  ## Who is f8 for
32
32
 
@@ -71,35 +71,36 @@ f8 guide --all # streams the full DSL, function, and auditing guide to stdout
71
71
  ```python
72
72
  from f8 import Model
73
73
 
74
+ years = ["2025A", "2026E", "2027E"]
75
+ capex = {"2025A": 25.0, "2026E": 30.0, "2027E": 35.0}
76
+ depr = {"2025A": -15.0, "2026E": -18.0, "2027E": -22.0}
77
+
74
78
  model = Model("Operating Forecast")
75
79
  sheet = model.add_sheet("Summary")
76
80
 
77
- pnl = sheet.add_table("PnL", "Three-Year Projection", "($ in millions)")
78
- pnl.add_column("item", role="descriptor", header="Line Item")
79
- for year in ["2025A", "2026E", "2027E"]:
80
- pnl.add_column(year, header=year, format="currency", decimals=1)
81
+ ppe = sheet.add_table("PPE", "PP&E Corkscrew Schedule", "($ in millions)")
82
+ ppe.add_column("item", role="descriptor", header="Line Item")
83
+ for year in years:
84
+ ppe.add_column(year, header=year, format="currency", decimals=1)
81
85
 
82
- pnl.add_row(
83
- "revenue",
84
- cells={"item": "Revenue", "2025A": 100.0},
85
- expr='PnL["revenue", col.prev] * 1.25',
86
- )
87
- pnl.add_row(
88
- "cogs",
89
- cells={"item": "Cost of Revenue"},
90
- expr='PnL["revenue", @col] * 0.30',
86
+ ppe.add_row(
87
+ "beg_balance",
88
+ cells={"item": "Beginning Balance", "2025A": 100.0},
89
+ expr='PPE["end_balance", col.prev]',
91
90
  )
92
- pnl.add_row(
93
- "gross_profit",
94
- cells={"item": "Gross Profit"},
95
- expr='PnL["revenue", @col] - PnL["cogs", @col]',
91
+ ppe.add_row("capex", cells={"item": "Capital Expenditures", **capex})
92
+ ppe.add_row("depreciation", cells={"item": "Depreciation", **depr})
93
+ ppe.add_row(
94
+ "end_balance",
95
+ cells={"item": "Ending Balance"},
96
+ expr='SUM(PPE["beg_balance".."depreciation", @col])',
96
97
  style="total",
97
98
  )
98
99
 
99
100
  compiled = model.compile()
100
101
 
101
102
  # Audit evaluated values in memory (or pass view="formulas" to inspect lowered Excel formulas)
102
- print(compiled.table("PnL").to_markdown())
103
+ print(compiled.table("PPE").to_markdown())
103
104
 
104
105
  # Emit a live .xlsx workbook with native Excel formulas and styling (you don't have to)
105
106
  compiled.save("forecast.xlsx")
@@ -108,11 +109,12 @@ compiled.save("forecast.xlsx")
108
109
  Output:
109
110
 
110
111
  ```markdown
111
- | Line Item | 2025A | 2026E | 2027E |
112
- | :--------------- | --------: | --------: | ---------: |
113
- | Revenue | $100.0 | $125.0 | $156.2 |
114
- | Cost of Revenue | $30.0 | $37.5 | $46.9 |
115
- | **Gross Profit** | **$70.0** | **$87.5** | **$109.4** |
112
+ | Line Item | 2025A | 2026E | 2027E |
113
+ | :------------------- | ---------: | ---------: | ---------: |
114
+ | Beginning Balance | $100.0 | $110.0 | $122.0 |
115
+ | Capital Expenditures | $25.0 | $30.0 | $35.0 |
116
+ | Depreciation | ($15.0) | ($18.0) | ($22.0) |
117
+ | **Ending Balance** | **$110.0** | **$122.0** | **$135.0** |
116
118
  ```
117
119
 
118
120
  ## Sample deliverables
@@ -1,6 +1,6 @@
1
1
  # f8
2
2
 
3
- Financial modeling engine with compiler verification and live Excel formulas.
3
+ Author, verify, and automate financial models with code and AI agents, compiled into live Excel workbooks.
4
4
 
5
5
  ## Who is f8 for
6
6
 
@@ -45,35 +45,36 @@ f8 guide --all # streams the full DSL, function, and auditing guide to stdout
45
45
  ```python
46
46
  from f8 import Model
47
47
 
48
+ years = ["2025A", "2026E", "2027E"]
49
+ capex = {"2025A": 25.0, "2026E": 30.0, "2027E": 35.0}
50
+ depr = {"2025A": -15.0, "2026E": -18.0, "2027E": -22.0}
51
+
48
52
  model = Model("Operating Forecast")
49
53
  sheet = model.add_sheet("Summary")
50
54
 
51
- pnl = sheet.add_table("PnL", "Three-Year Projection", "($ in millions)")
52
- pnl.add_column("item", role="descriptor", header="Line Item")
53
- for year in ["2025A", "2026E", "2027E"]:
54
- pnl.add_column(year, header=year, format="currency", decimals=1)
55
+ ppe = sheet.add_table("PPE", "PP&E Corkscrew Schedule", "($ in millions)")
56
+ ppe.add_column("item", role="descriptor", header="Line Item")
57
+ for year in years:
58
+ ppe.add_column(year, header=year, format="currency", decimals=1)
55
59
 
56
- pnl.add_row(
57
- "revenue",
58
- cells={"item": "Revenue", "2025A": 100.0},
59
- expr='PnL["revenue", col.prev] * 1.25',
60
- )
61
- pnl.add_row(
62
- "cogs",
63
- cells={"item": "Cost of Revenue"},
64
- expr='PnL["revenue", @col] * 0.30',
60
+ ppe.add_row(
61
+ "beg_balance",
62
+ cells={"item": "Beginning Balance", "2025A": 100.0},
63
+ expr='PPE["end_balance", col.prev]',
65
64
  )
66
- pnl.add_row(
67
- "gross_profit",
68
- cells={"item": "Gross Profit"},
69
- expr='PnL["revenue", @col] - PnL["cogs", @col]',
65
+ ppe.add_row("capex", cells={"item": "Capital Expenditures", **capex})
66
+ ppe.add_row("depreciation", cells={"item": "Depreciation", **depr})
67
+ ppe.add_row(
68
+ "end_balance",
69
+ cells={"item": "Ending Balance"},
70
+ expr='SUM(PPE["beg_balance".."depreciation", @col])',
70
71
  style="total",
71
72
  )
72
73
 
73
74
  compiled = model.compile()
74
75
 
75
76
  # Audit evaluated values in memory (or pass view="formulas" to inspect lowered Excel formulas)
76
- print(compiled.table("PnL").to_markdown())
77
+ print(compiled.table("PPE").to_markdown())
77
78
 
78
79
  # Emit a live .xlsx workbook with native Excel formulas and styling (you don't have to)
79
80
  compiled.save("forecast.xlsx")
@@ -82,11 +83,12 @@ compiled.save("forecast.xlsx")
82
83
  Output:
83
84
 
84
85
  ```markdown
85
- | Line Item | 2025A | 2026E | 2027E |
86
- | :--------------- | --------: | --------: | ---------: |
87
- | Revenue | $100.0 | $125.0 | $156.2 |
88
- | Cost of Revenue | $30.0 | $37.5 | $46.9 |
89
- | **Gross Profit** | **$70.0** | **$87.5** | **$109.4** |
86
+ | Line Item | 2025A | 2026E | 2027E |
87
+ | :------------------- | ---------: | ---------: | ---------: |
88
+ | Beginning Balance | $100.0 | $110.0 | $122.0 |
89
+ | Capital Expenditures | $25.0 | $30.0 | $35.0 |
90
+ | Depreciation | ($15.0) | ($18.0) | ($22.0) |
91
+ | **Ending Balance** | **$110.0** | **$122.0** | **$135.0** |
90
92
  ```
91
93
 
92
94
  ## Sample deliverables
@@ -144,6 +144,7 @@ mod tests {
144
144
  subtitle: None,
145
145
  currency: None,
146
146
  collapsible: false,
147
+ placement: crate::model::table::PlacementSpec::default(),
147
148
  columns: vec![
148
149
  ColumnSpec::new("c1"),
149
150
  ColumnSpec::new("c2"),
@@ -4,7 +4,9 @@ use super::error::ResolveError;
4
4
  use crate::compiler::ir::cell::{CellGrid, CellPayload};
5
5
  use crate::compiler::ir::id::CellId;
6
6
  use crate::compiler::ir::symbol::{SymbolTable, TableMetadata};
7
- use crate::expr::ast::{ColSelector, Expr, RelativeOffset, RowSelector, TableRef};
7
+ use crate::expr::ast::{
8
+ ColEndpoint, ColSelector, Expr, RelativeOffset, RowEndpoint, RowSelector, TableRef,
9
+ };
8
10
  use crate::model::id::{ColId, RowId};
9
11
 
10
12
  /// Recursively collects all table references present within a formula expression AST.
@@ -32,6 +34,70 @@ fn collect_refs_recursive<'a>(expr: &'a Expr, out: &mut Vec<&'a TableRef>) {
32
34
  }
33
35
  }
34
36
 
37
+ /// Resolves an explicit row key to its 0-based index and canonical [`RowId`] in `target_table.rows`.
38
+ fn resolve_row_key<'a>(
39
+ origin: &CellId,
40
+ target_table: &'a TableMetadata,
41
+ row_id: &RowId,
42
+ ) -> Result<(usize, &'a RowId), ResolveError> {
43
+ target_table.rows.get_full(row_id).ok_or_else(|| {
44
+ ResolveError::unknown_row(origin, target_table.table_id.clone(), row_id.clone())
45
+ })
46
+ }
47
+
48
+ /// Resolves a relative row offset (`row.prev`, `row.next`) to its 0-based index and [`RowId`] in `target_table.rows`.
49
+ fn resolve_row_offset<'a>(
50
+ origin: &CellId,
51
+ target_table: &'a TableMetadata,
52
+ origin_table: &TableMetadata,
53
+ offset: RelativeOffset,
54
+ ) -> Result<(usize, &'a RowId), ResolveError> {
55
+ let origin_idx = origin_table.rows.get_index_of(&origin.row).ok_or_else(|| {
56
+ ResolveError::unknown_row(origin, origin_table.table_id.clone(), origin.row.clone())
57
+ })?;
58
+
59
+ let signed_offset = match offset {
60
+ RelativeOffset::Prev(k) => -(k as i32),
61
+ RelativeOffset::Next(k) => k as i32,
62
+ };
63
+ let target_idx = match offset {
64
+ RelativeOffset::Prev(k) => origin_idx
65
+ .checked_sub(k as usize)
66
+ .ok_or_else(|| ResolveError::temporal_offset_out_of_bounds(origin, signed_offset))?,
67
+ RelativeOffset::Next(k) => origin_idx
68
+ .checked_add(k as usize)
69
+ .ok_or_else(|| ResolveError::temporal_offset_out_of_bounds(origin, signed_offset))?,
70
+ };
71
+
72
+ let target_row_id = origin_table
73
+ .rows
74
+ .get_index(target_idx)
75
+ .ok_or_else(|| ResolveError::temporal_offset_out_of_bounds(origin, signed_offset))?;
76
+ resolve_row_key(origin, target_table, target_row_id)
77
+ }
78
+
79
+ /// Resolves a single row endpoint to its 0-based index and canonical [`RowId`] in `target_table.rows`.
80
+ fn resolve_row_endpoint<'a>(
81
+ origin: &CellId,
82
+ target_table: &'a TableMetadata,
83
+ origin_table: &TableMetadata,
84
+ endpoint: &RowEndpoint,
85
+ ) -> Result<(usize, &'a RowId), ResolveError> {
86
+ match endpoint {
87
+ RowEndpoint::Active => target_table.rows.get_full(&origin.row).ok_or_else(|| {
88
+ ResolveError::active_row_mismatch(
89
+ origin,
90
+ target_table.table_id.clone(),
91
+ origin.row.clone(),
92
+ )
93
+ }),
94
+ RowEndpoint::Key(row_id) => resolve_row_key(origin, target_table, row_id),
95
+ RowEndpoint::Offset(offset) => {
96
+ resolve_row_offset(origin, target_table, origin_table, *offset)
97
+ }
98
+ }
99
+ }
100
+
35
101
  /// Resolves a row selector against target and origin table metadata.
36
102
  pub(crate) fn resolve_row_selector(
37
103
  origin: &CellId,
@@ -41,110 +107,134 @@ pub(crate) fn resolve_row_selector(
41
107
  ) -> Result<Vec<RowId>, ResolveError> {
42
108
  match row_selector {
43
109
  RowSelector::Active => {
44
- if !target_table.rows.contains(&origin.row) {
45
- return Err(ResolveError::active_row_mismatch(
46
- origin,
47
- target_table.table_id.clone(),
48
- origin.row.clone(),
49
- ));
50
- }
51
- Ok(vec![origin.row.clone()])
110
+ let (_, id) =
111
+ resolve_row_endpoint(origin, target_table, origin_table, &RowEndpoint::Active)?;
112
+ Ok(vec![id.clone()])
52
113
  }
53
114
  RowSelector::Key(row_id) => {
54
- if !target_table.rows.contains(row_id) {
55
- return Err(ResolveError::unknown_row(
56
- origin,
57
- target_table.table_id.clone(),
58
- row_id.clone(),
59
- ));
60
- }
61
- Ok(vec![row_id.clone()])
115
+ let (_, id) = resolve_row_key(origin, target_table, row_id)?;
116
+ Ok(vec![id.clone()])
117
+ }
118
+ RowSelector::Offset(offset) => {
119
+ let (_, id) = resolve_row_offset(origin, target_table, origin_table, *offset)?;
120
+ Ok(vec![id.clone()])
62
121
  }
63
122
  RowSelector::Range(start, end) => {
64
- let start_idx = target_table.rows.get_index_of(start).ok_or_else(|| {
65
- ResolveError::unknown_row(origin, target_table.table_id.clone(), start.clone())
66
- })?;
67
- let end_idx = target_table.rows.get_index_of(end).ok_or_else(|| {
68
- ResolveError::unknown_row(origin, target_table.table_id.clone(), end.clone())
69
- })?;
123
+ let (start_idx, start_id) =
124
+ resolve_row_endpoint(origin, target_table, origin_table, start)?;
125
+ let (end_idx, end_id) = resolve_row_endpoint(origin, target_table, origin_table, end)?;
70
126
 
71
127
  if start_idx > end_idx {
72
128
  return Err(ResolveError::inverted_range(
73
129
  origin,
74
130
  target_table.table_id.clone(),
75
- start.to_string(),
76
- end.to_string(),
131
+ start_id.to_string(),
132
+ end_id.to_string(),
77
133
  ));
78
134
  }
79
135
 
80
- Ok(target_table.rows[start_idx..=end_idx]
81
- .iter()
82
- .cloned()
83
- .collect())
84
- }
85
- RowSelector::Offset(offset) => match offset {
86
- RelativeOffset::Prev(k) => {
87
- let origin_idx = origin_table.rows.get_index_of(&origin.row).ok_or_else(|| {
88
- ResolveError::unknown_row(
136
+ let slice = target_table
137
+ .rows
138
+ .get_range(start_idx..=end_idx)
139
+ .ok_or_else(|| {
140
+ ResolveError::inverted_range(
89
141
  origin,
90
- origin_table.table_id.clone(),
91
- origin.row.clone(),
142
+ target_table.table_id.clone(),
143
+ start_id.to_string(),
144
+ end_id.to_string(),
92
145
  )
93
146
  })?;
94
147
 
95
- let k_usize = *k as usize;
96
- if origin_idx < k_usize {
97
- return Err(ResolveError::temporal_offset_out_of_bounds(
98
- origin,
99
- -(*k as i32),
100
- ));
101
- }
148
+ Ok(slice.iter().cloned().collect())
149
+ }
150
+ }
151
+ }
102
152
 
103
- let target_idx = origin_idx - k_usize;
104
- let target_row_id = origin_table.rows[target_idx].clone();
153
+ /// Resolves an explicit column key to its 0-based index and canonical [`ColId`] in `target_table.columns`.
154
+ fn resolve_col_key<'a>(
155
+ origin: &CellId,
156
+ target_table: &'a TableMetadata,
157
+ col_id: &ColId,
158
+ ) -> Result<(usize, &'a ColId), ResolveError> {
159
+ let idx = target_table
160
+ .column_index
161
+ .get(col_id)
162
+ .copied()
163
+ .ok_or_else(|| {
164
+ ResolveError::unknown_column(origin, target_table.table_id.clone(), col_id.clone())
165
+ })?;
166
+ let col = target_table.columns.get(idx).ok_or_else(|| {
167
+ ResolveError::unknown_column(origin, target_table.table_id.clone(), col_id.clone())
168
+ })?;
169
+ Ok((idx, &col.id))
170
+ }
105
171
 
106
- if !target_table.rows.contains(&target_row_id) {
107
- return Err(ResolveError::unknown_row(
108
- origin,
109
- target_table.table_id.clone(),
110
- target_row_id,
111
- ));
112
- }
172
+ /// Resolves a relative column offset (`col.prev`, `col.next`) to its 0-based index and [`ColId`] in `target_table.columns`.
173
+ fn resolve_col_offset<'a>(
174
+ origin: &CellId,
175
+ target_table: &'a TableMetadata,
176
+ origin_table: &TableMetadata,
177
+ offset: RelativeOffset,
178
+ ) -> Result<(usize, &'a ColId), ResolveError> {
179
+ let signed_offset = match offset {
180
+ RelativeOffset::Prev(k) => -(k as i32),
181
+ RelativeOffset::Next(k) => k as i32,
182
+ };
183
+ let origin_measure_idx = origin_table
184
+ .measure_columns
185
+ .iter()
186
+ .position(|c| c == &origin.col)
187
+ .ok_or_else(|| ResolveError::temporal_offset_out_of_bounds(origin, signed_offset))?;
188
+
189
+ let target_measure_idx = match offset {
190
+ RelativeOffset::Prev(k) => origin_measure_idx
191
+ .checked_sub(k as usize)
192
+ .ok_or_else(|| ResolveError::temporal_offset_out_of_bounds(origin, signed_offset))?,
193
+ RelativeOffset::Next(k) => origin_measure_idx
194
+ .checked_add(k as usize)
195
+ .ok_or_else(|| ResolveError::temporal_offset_out_of_bounds(origin, signed_offset))?,
196
+ };
197
+
198
+ let target_col_id = origin_table
199
+ .measure_columns
200
+ .get(target_measure_idx)
201
+ .ok_or_else(|| ResolveError::temporal_offset_out_of_bounds(origin, signed_offset))?;
202
+ resolve_col_key(origin, target_table, target_col_id)
203
+ }
113
204
 
114
- Ok(vec![target_row_id])
115
- }
116
- RelativeOffset::Next(k) => {
117
- let origin_idx = origin_table.rows.get_index_of(&origin.row).ok_or_else(|| {
118
- ResolveError::unknown_row(
205
+ /// Resolves a single column endpoint to its 0-based index and canonical [`ColId`] in `target_table.columns`.
206
+ fn resolve_col_endpoint<'a>(
207
+ origin: &CellId,
208
+ target_table: &'a TableMetadata,
209
+ origin_table: &TableMetadata,
210
+ endpoint: &ColEndpoint,
211
+ ) -> Result<(usize, &'a ColId), ResolveError> {
212
+ match endpoint {
213
+ ColEndpoint::Active => {
214
+ let idx = target_table
215
+ .column_index
216
+ .get(&origin.col)
217
+ .copied()
218
+ .ok_or_else(|| {
219
+ ResolveError::active_column_mismatch(
119
220
  origin,
120
- origin_table.table_id.clone(),
121
- origin.row.clone(),
221
+ target_table.table_id.clone(),
222
+ origin.col.clone(),
122
223
  )
123
224
  })?;
124
-
125
- let k_usize = *k as usize;
126
- let target_idx = match origin_idx.checked_add(k_usize) {
127
- Some(idx) if idx < origin_table.rows.len() => idx,
128
- _ => {
129
- return Err(ResolveError::temporal_offset_out_of_bounds(
130
- origin, *k as i32,
131
- ));
132
- }
133
- };
134
-
135
- let target_row_id = origin_table.rows[target_idx].clone();
136
-
137
- if !target_table.rows.contains(&target_row_id) {
138
- return Err(ResolveError::unknown_row(
139
- origin,
140
- target_table.table_id.clone(),
141
- target_row_id,
142
- ));
143
- }
144
-
145
- Ok(vec![target_row_id])
146
- }
147
- },
225
+ let col = target_table.columns.get(idx).ok_or_else(|| {
226
+ ResolveError::active_column_mismatch(
227
+ origin,
228
+ target_table.table_id.clone(),
229
+ origin.col.clone(),
230
+ )
231
+ })?;
232
+ Ok((idx, &col.id))
233
+ }
234
+ ColEndpoint::Key(col_id) => resolve_col_key(origin, target_table, col_id),
235
+ ColEndpoint::Offset(offset) => {
236
+ resolve_col_offset(origin, target_table, origin_table, *offset)
237
+ }
148
238
  }
149
239
  }
150
240
 
@@ -157,119 +247,46 @@ pub(crate) fn resolve_col_selector(
157
247
  ) -> Result<Vec<ColId>, ResolveError> {
158
248
  match col_selector {
159
249
  ColSelector::Active => {
160
- if !target_table.column_index.contains_key(&origin.col) {
161
- return Err(ResolveError::active_column_mismatch(
162
- origin,
163
- target_table.table_id.clone(),
164
- origin.col.clone(),
165
- ));
166
- }
167
- Ok(vec![origin.col.clone()])
250
+ let (_, id) =
251
+ resolve_col_endpoint(origin, target_table, origin_table, &ColEndpoint::Active)?;
252
+ Ok(vec![id.clone()])
168
253
  }
169
254
  ColSelector::Key(col_id) => {
170
- if !target_table.column_index.contains_key(col_id) {
171
- return Err(ResolveError::unknown_column(
172
- origin,
173
- target_table.table_id.clone(),
174
- col_id.clone(),
175
- ));
176
- }
177
- Ok(vec![col_id.clone()])
255
+ let (_, id) = resolve_col_key(origin, target_table, col_id)?;
256
+ Ok(vec![id.clone()])
257
+ }
258
+ ColSelector::Offset(offset) => {
259
+ let (_, id) = resolve_col_offset(origin, target_table, origin_table, *offset)?;
260
+ Ok(vec![id.clone()])
178
261
  }
179
262
  ColSelector::Range(start, end) => {
180
- let start_idx = target_table
181
- .column_index
182
- .get(start)
183
- .copied()
184
- .ok_or_else(|| {
185
- ResolveError::unknown_column(
186
- origin,
187
- target_table.table_id.clone(),
188
- start.clone(),
189
- )
190
- })?;
191
- let end_idx = target_table.column_index.get(end).copied().ok_or_else(|| {
192
- ResolveError::unknown_column(origin, target_table.table_id.clone(), end.clone())
193
- })?;
263
+ let (start_idx, start_id) =
264
+ resolve_col_endpoint(origin, target_table, origin_table, start)?;
265
+ let (end_idx, end_id) = resolve_col_endpoint(origin, target_table, origin_table, end)?;
194
266
 
195
267
  if start_idx > end_idx {
196
268
  return Err(ResolveError::inverted_range(
197
269
  origin,
198
270
  target_table.table_id.clone(),
199
- start.to_string(),
200
- end.to_string(),
271
+ start_id.to_string(),
272
+ end_id.to_string(),
201
273
  ));
202
274
  }
203
275
 
204
- let cols = target_table.columns[start_idx..=end_idx]
205
- .iter()
206
- .map(|c| c.id.clone())
207
- .collect();
208
- Ok(cols)
209
- }
210
- ColSelector::Offset(offset) => match offset {
211
- RelativeOffset::Prev(k) => {
212
- let origin_measure_idx = origin_table
213
- .measure_columns
214
- .iter()
215
- .position(|c| c == &origin.col)
216
- .ok_or_else(|| {
217
- ResolveError::temporal_offset_out_of_bounds(origin, -(*k as i32))
218
- })?;
219
-
220
- let k_usize = *k as usize;
221
- if origin_measure_idx < k_usize {
222
- return Err(ResolveError::temporal_offset_out_of_bounds(
223
- origin,
224
- -(*k as i32),
225
- ));
226
- }
227
-
228
- let target_measure_idx = origin_measure_idx - k_usize;
229
- let target_col_id = origin_table.measure_columns[target_measure_idx].clone();
230
-
231
- if !target_table.column_index.contains_key(&target_col_id) {
232
- return Err(ResolveError::unknown_column(
233
- origin,
234
- target_table.table_id.clone(),
235
- target_col_id,
236
- ));
237
- }
238
-
239
- Ok(vec![target_col_id])
240
- }
241
- RelativeOffset::Next(k) => {
242
- let origin_measure_idx = origin_table
243
- .measure_columns
244
- .iter()
245
- .position(|c| c == &origin.col)
246
- .ok_or_else(|| {
247
- ResolveError::temporal_offset_out_of_bounds(origin, *k as i32)
248
- })?;
249
-
250
- let k_usize = *k as usize;
251
- let target_measure_idx = match origin_measure_idx.checked_add(k_usize) {
252
- Some(idx) if idx < origin_table.measure_columns.len() => idx,
253
- _ => {
254
- return Err(ResolveError::temporal_offset_out_of_bounds(
255
- origin, *k as i32,
256
- ));
257
- }
258
- };
259
-
260
- let target_col_id = origin_table.measure_columns[target_measure_idx].clone();
261
-
262
- if !target_table.column_index.contains_key(&target_col_id) {
263
- return Err(ResolveError::unknown_column(
276
+ let slice = target_table
277
+ .columns
278
+ .get(start_idx..=end_idx)
279
+ .ok_or_else(|| {
280
+ ResolveError::inverted_range(
264
281
  origin,
265
282
  target_table.table_id.clone(),
266
- target_col_id,
267
- ));
268
- }
283
+ start_id.to_string(),
284
+ end_id.to_string(),
285
+ )
286
+ })?;
269
287
 
270
- Ok(vec![target_col_id])
271
- }
272
- },
288
+ Ok(slice.iter().map(|c| c.id.clone()).collect())
289
+ }
273
290
  }
274
291
  }
275
292
 
@@ -425,6 +442,7 @@ mod tests {
425
442
  subtitle: None,
426
443
  currency: None,
427
444
  collapsible: false,
445
+ placement: crate::model::table::PlacementSpec::default(),
428
446
  columns: vec![ColumnSpec::new("2024E")],
429
447
  rows: vec![RowSpec::new("rev")],
430
448
  };