Compare commits

...

207 Commits

Author SHA1 Message Date
nym21 67b2897a8c release: v0.1.3 2026-02-03 00:35:14 +01:00
nym21 519e7c4179 docs: update generated docs 2026-02-03 00:34:54 +01:00
nym21 36bc1fb491 deps: upgrade 2026-02-03 00:18:58 +01:00
nym21 9e3fe4e557 website: snapshot 2026-02-03 00:08:37 +01:00
nym21 a6d8278730 website: snapshot 2026-02-02 19:20:04 +01:00
nym21 b23d20ea05 website: snapshot 2026-02-02 18:39:42 +01:00
nym21 cf4bc470e4 website: snapshot 2026-02-02 13:51:50 +01:00
nym21 da923e409a website: snapshot 2026-02-02 12:44:16 +01:00
nym21 f7d7c5704a global: snapshot 2026-02-01 22:38:01 +01:00
nym21 f03bbd9a92 website: options: objectify 2026-01-31 17:51:27 +01:00
nym21 ff5bb770d7 global: snapshot 2026-01-31 17:39:48 +01:00
nym21 8dd350264a changelog: updated 2026-01-29 12:42:45 +01:00
nym21 cde090685a release: v0.1.2 2026-01-29 12:30:45 +01:00
nym21 a9f1dad091 docs: update generated docs 2026-01-29 12:30:26 +01:00
nym21 54827cd0a2 log + help: improved 2026-01-29 11:54:38 +01:00
nym21 e01bb53b2e indexer: remove rollback test 2026-01-28 23:44:57 +01:00
nym21 9f2b808cdb deps: upgrade 2026-01-28 23:39:07 +01:00
nym21 6709ded66c global: reorg fixes + clients improved 2026-01-28 23:35:51 +01:00
nym21 fecaf0f400 bindgen: determinism 2026-01-27 23:48:19 +01:00
nym21 730e83472a ci: outdated 2026-01-27 17:52:40 +01:00
nym21 88145d08e5 release: v0.1.1 2026-01-27 01:45:01 +01:00
nym21 c367802b4a docs: update generated docs 2026-01-27 01:44:43 +01:00
nym21 3d36524707 scripts: split release 2026-01-27 01:42:24 +01:00
nym21 6cdc5879bb server: fix html caching rules 2026-01-27 01:39:09 +01:00
nym21 79d14cd260 docs: update generated docs 2026-01-27 01:18:15 +01:00
nym21 f6020b32a7 release: v0.1.0 2026-01-27 00:58:54 +01:00
nym21 aa5c4a8d69 docs: update generated docs 2026-01-27 00:58:45 +01:00
nym21 ec1f2de5cf global: snapshot 2026-01-27 00:30:58 +01:00
nym21 3d01822d27 global: sats version of all prices 2026-01-26 15:04:45 +01:00
nym21 f066fcda32 release: v0.1.0-beta.1 2026-01-26 11:16:04 +01:00
nym21 b3b4df0fc7 docs: update generated docs 2026-01-26 11:15:44 +01:00
nym21 616a97d242 docs: update generated docs 2026-01-26 10:54:26 +01:00
nym21 d9dabb4a96 types: added fract sats 2026-01-26 10:43:26 +01:00
nym21 371fb2cb17 investing: more data + charts 2026-01-26 10:28:26 +01:00
nym21 5c824e50b8 website: snapshot 2026-01-25 21:55:55 +01:00
nym21 fbe99e33cd website: snapshot 2026-01-25 20:27:28 +01:00
nym21 35bf1afcff website: snapshot 2026-01-25 20:11:32 +01:00
nym21 543cde525e scripts: update release 2026-01-25 14:36:46 +01:00
nym21 dad7780ab8 scripts: update release 2026-01-25 14:36:34 +01:00
nym21 eb941778f2 release: v0.1.0-beta.0 2026-01-25 14:20:57 +01:00
nym21 b7acce6527 docs: update generated docs 2026-01-25 14:20:22 +01:00
nym21 247d3c758b docs: update generated docs 2026-01-25 14:12:03 +01:00
nym21 79f7e89740 scripts: update release 2026-01-25 14:09:32 +01:00
nym21 8d7bcbd947 scripts: update release 2026-01-25 14:08:52 +01:00
nym21 23a59806c2 docs: update generated docs 2026-01-25 13:56:07 +01:00
nym21 1e76e137ab scripts: update release 2026-01-25 13:51:42 +01:00
nym21 cef03c495f docs: update generated docs 2026-01-25 13:42:24 +01:00
nym21 36b56a400c website: snapshot 2026-01-25 13:16:00 +01:00
nym21 c6f63fd4a2 website: snapshot 2026-01-25 12:42:16 +01:00
nym21 7cdf47a9e4 website: snapshot 2026-01-24 19:22:03 +01:00
nym21 9b706dfaee website: snapshot 2026-01-23 22:03:01 +01:00
nym21 f7bfe5ecaa website: big snapshot + cleanup 2026-01-23 00:25:11 +01:00
nym21 6ef43ce7ff website: swap ufuzzy for quickmatch 2026-01-22 18:32:57 +01:00
nym21 3c87d36535 website: snapshot 2026-01-22 17:16:07 +01:00
nym21 a62a377081 website: snapshot 2026-01-22 16:21:09 +01:00
nym21 b557477770 website: snapshot 2026-01-22 15:12:56 +01:00
nym21 bf13249003 website: snapshot 2026-01-22 13:19:50 +01:00
nym21 31c5a5dde5 website: snapshot 2026-01-22 11:11:13 +01:00
nym21 758256a1a2 website: snapshot 2026-01-22 10:38:56 +01:00
nym21 c660cb4e89 website: snapshot 2026-01-22 10:32:03 +01:00
nym21 0512dcaf4f website: snapshot 2026-01-22 10:12:03 +01:00
nym21 d1075afc02 website: snapshot 2026-01-22 09:17:12 +01:00
nym21 f037f01b27 website: snapshot 2026-01-22 01:38:22 +01:00
nym21 65e563a889 website: snapshot 2026-01-22 01:12:55 +01:00
nym21 bd18297af3 website: snapshot 2026-01-22 01:12:55 +01:00
nym21 77505ca7cb website: snapshot 2026-01-22 01:12:55 +01:00
nym21 c22c16044c website: snapshot 2026-01-22 01:12:55 +01:00
nym21 889a70efdd website: snapshot 2026-01-22 01:12:55 +01:00
nym21 2386020639 website: snapshot 2026-01-22 01:12:55 +01:00
nym21 60adac0eb7 merge: #28 brandoncollins7/feat/reserve-risk
feat(cointime): add Reserve Risk metric
2026-01-21 20:34:06 +01:00
Brandon Collins 95686ae858 merge: resolve Cargo.toml conflict with upstream/main
Keep vecdb = 0.6.1 without commented path dependency.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:27:09 -05:00
Brandon Collins fd4cf5d414 refactor: remove verbose comments from vecs.rs files 2026-01-21 14:08:27 -05:00
Brandon Collins 49794c5e04 refactor: remove verbose comments from tests 2026-01-21 14:07:20 -05:00
nym21 e29387f3c1 website: snapshot 2026-01-21 18:39:32 +01:00
Brandon Collins 581a800612 refactor: reduce verbosity and use vecdb cumulative function
- Remove verbose inline comments from compute.rs
- Update vecdb to 0.6.1
- Refactor HODL Bank to use compute_cumulative_transformed_binary
2026-01-21 12:20:53 -05:00
nym21 1456f47fd1 website: snapshot 2026-01-21 14:00:31 +01:00
nym21 a9b2da86ff website: snapshot 2026-01-21 11:55:53 +01:00
nym21 6c67dc4a98 global: fixes 2026-01-21 00:26:35 +01:00
nym21 2edd9ed2d7 global: snapshot 2026-01-20 23:05:21 +01:00
Brandon Collins 9dda513f84 revert: restore original vecdb path dependency
Reverts Cargo.toml to use the local anydb/vecdb path as the upstream
repo expects. For contributors without the local anydb repo, the
crates.io vecdb 0.6.0 version can be used temporarily.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 11:15:58 -05:00
Brandon Collins 5ecfd6cd42 fix: address vecdb compatibility and add unit tests
- Switch to vecdb 0.6.0 for compatibility with brk_types u8/i8
- Add proper trait imports (VecIndex, AnyVec, IterableVec, etc.)
- Add unit tests for Reserve Risk formula validation:
  - test_hodl_bank_formula: Verifies cumulative calculation
  - test_reserve_risk_formula: Verifies division formula
  - test_reserve_risk_interpretation: Documents metric semantics
  - test_hodl_bank_negative_contribution: Tests edge case

All 16 tests pass (12 existing + 4 new).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 11:15:30 -05:00
Brandon Collins f494486e12 feat(cointime): add Reserve Risk metric
Implements Reserve Risk as a new market indicator for Bitcoin.

## Formula
- Reserve Risk = Price / HODL Bank
- HODL Bank = cumulative Σ(Price - avg_VOCDD) over time
- VOCDD = CDD × Price (Value-weighted Coin Days Destroyed)

## Changes
- Added `vocdd` (Value-weighted CDD) to `cointime/value` module
- Created new `cointime/reserve_risk` module containing:
  - `vocdd_365d_sma`: 365-day moving average of VOCDD
  - `hodl_bank`: Cumulative opportunity cost of holding
  - `reserve_risk`: Final ratio metric for timing accumulation
- Wired into cointime compute pipeline (price-dependent)

## Use Case
Reserve Risk measures long-term holder confidence.
Low values indicate high confidence and potential buying opportunity.
High values suggest overheated market conditions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:53:20 -05:00
nym21 9613fce919 global: snapshot 2026-01-20 15:04:00 +01:00
nym21 486871379c global: snapshot 2026-01-19 16:54:47 +01:00
nym21 fba0550dda global: snapshot 2026-01-19 16:54:08 +01:00
nym21 371ff86287 global: snapshot 2026-01-19 16:52:17 +01:00
nym21 c90953adbe global: snapshot 2026-01-18 16:04:24 +01:00
nym21 4031bf3e79 clients: released 2026-01-18 00:42:14 +01:00
nym21 9adaff488a release: v0.1.0-alpha.6 2026-01-18 00:19:59 +01:00
nym21 9f6168915f docs: update generated docs 2026-01-18 00:19:09 +01:00
nym21 64b90dd678 release: auto accept version 2026-01-18 00:08:20 +01:00
nym21 93e02aed44 client: fix minreq feat + publish: add full workspace check 2026-01-17 23:40:18 +01:00
nym21 8302660d88 release: v0.1.0-alpha.5 2026-01-17 23:26:43 +01:00
nym21 2c0e3d1119 docs: update generated docs 2026-01-17 23:21:35 +01:00
nym21 7bbf03766e query: fix features 2026-01-17 23:07:47 +01:00
nym21 7a2ba17d20 release: v0.1.0-alpha.4 2026-01-17 22:48:29 +01:00
nym21 ac30f0e512 docs: update generated docs 2026-01-17 22:48:00 +01:00
nym21 2e1037ff36 global: snapshot 2026-01-17 22:35:13 +01:00
nym21 626c52044d js: readme 2026-01-17 12:49:38 +01:00
nym21 f7ee4e487a server: snapshot 2026-01-17 11:23:04 +01:00
nym21 7b3e172948 global: snapshot 2026-01-17 02:34:08 +01:00
nym21 6bb1a2a311 global: snapshot 2026-01-16 23:49:49 +01:00
nym21 3b00a92fa4 global: snapshot 2026-01-16 15:17:42 +01:00
nym21 f39681bb2b price: snapshot 2026-01-16 00:41:25 +01:00
nym21 967d2c7f35 global: snapshot 2026-01-15 23:34:43 +01:00
nym21 b0d933a7ab publish: snapshot 2026-01-14 23:14:28 +01:00
nym21 96e0df110e server: add symlink to website 2026-01-14 23:00:31 +01:00
nym21 91a6129e8d release: v0.1.0-alpha.3 2026-01-14 22:47:25 +01:00
nym21 d9c829c3c6 docs: update generated docs 2026-01-14 22:46:51 +01:00
nym21 467dfcc4b8 global: snapshot 2026-01-14 22:20:23 +01:00
nym21 8a938c00f6 readme: updated 2026-01-14 20:40:42 +01:00
nym21 5661735f3e readme: updated 2026-01-14 20:35:07 +01:00
nym21 1c7434ff83 global: snapshot 2026-01-14 20:09:51 +01:00
nym21 d75c2a881b global: snapshot 2026-01-14 16:38:53 +01:00
nym21 ddb1db7a8e clients: snapshot 2026-01-14 11:12:31 +01:00
nym21 407a365055 clients: snapshot 2026-01-14 10:33:58 +01:00
nym21 335cbce09e clients: snapshot 2026-01-14 10:07:27 +01:00
nym21 922a0abb60 clients: snapshot 2026-01-14 09:37:43 +01:00
nym21 25a0ebe51e clients: snapshot 2026-01-14 01:20:25 +01:00
nym21 3a836ab0f4 clients: snapshot 2026-01-14 00:39:28 +01:00
nym21 524ab3de05 clients: snapshot 2026-01-13 23:14:26 +01:00
nym21 e77993fb76 global: snapshot 2026-01-13 22:32:29 +01:00
nym21 0c442b4a71 computer: shorten percentiles path in tree 2026-01-13 01:49:13 +01:00
nym21 670aa95494 global: snapshot 2026-01-13 01:18:27 +01:00
nym21 5ffb66c0dc global: snapshot 2026-01-12 22:43:56 +01:00
nym21 b675b70067 global: snapshot 2026-01-12 16:19:23 +01:00
nym21 1484eae53c server: endpoint description 2026-01-12 12:38:34 +01:00
nym21 b12a72ea1a server: snapshot 2026-01-12 12:34:30 +01:00
nym21 1b9e18f98b global: snapshot 2026-01-12 11:39:44 +01:00
nym21 8fe0af349d clients: snapshot 2026-01-12 08:48:12 +01:00
nym21 5826d78e35 clients: snapshot 2026-01-11 23:08:08 +01:00
nym21 325811fee7 clients: snapshot 2026-01-11 19:15:29 +01:00
nym21 69f6d32d4a global: snapshot 2026-01-11 18:55:40 +01:00
nym21 ea70c381de global: snapshot 2026-01-11 17:19:00 +01:00
nym21 6f45ec13f3 global: snapshot 2026-01-10 18:43:18 +01:00
nym21 3bc0615000 computer: renames 2026-01-10 10:23:29 +01:00
nym21 69729842a4 computer: renames 2026-01-09 23:40:00 +01:00
nym21 5f4fc646f5 computer: renames 2026-01-09 23:27:09 +01:00
nym21 85570c73cb deps: upgrade 2026-01-09 22:04:37 +01:00
nym21 3a3f6b8593 computer: snapshot 2026-01-09 22:02:34 +01:00
nym21 426d7797a3 global: big snapshot 2026-01-09 20:00:20 +01:00
nym21 cb0abc324e global: MASSIVE snapshot 2026-01-07 01:16:37 +01:00
nym21 e832ffbe23 bindgen: snapshot 2026-01-06 13:48:29 +01:00
nym21 abffdec497 crates: snapshot 2026-01-04 13:20:30 +01:00
nym21 70e7e24b4f release: v0.1.0-alpha.2 2026-01-04 11:54:27 +01:00
nym21 13ab7d39d7 global: snapshot 2026-01-04 11:51:22 +01:00
nym21 3cae817915 global: BIG snapshot 2026-01-04 01:47:03 +01:00
nym21 c33444a92e global: snapshot 2026-01-02 19:23:20 +01:00
nym21 3e9b1cc2b2 global: MASSIVE snapshot 2026-01-02 19:08:20 +01:00
nym21 ac6175688d crates: snapshot 2025-12-31 00:02:50 +01:00
nym21 a6f8108165 crates: snapshot 2025-12-30 22:49:47 +01:00
nym21 8cff55a405 crates: snapshot 2025-12-30 18:09:08 +01:00
nym21 bd376f86ea crates: snapshot 2025-12-30 11:48:09 +01:00
nym21 d9f28e85af crates: snapshot 2025-12-30 11:27:39 +01:00
nym21 ed18fd55e1 crates: snapshot 2025-12-30 00:49:34 +01:00
nym21 5b06098368 binder: snapshot 2025-12-29 20:01:43 +01:00
nym21 e89a67b9a7 global: snapshot 2025-12-29 17:02:17 +01:00
nym21 445959f5b9 global: snapshot 2025-12-29 13:20:52 +01:00
nym21 647f177f31 binder: commit generated clients 2025-12-29 09:37:57 +01:00
nym21 705dbdbd7e modules: update deps 2025-12-29 09:32:51 +01:00
nym21 31d2f8ef37 computer: snapshot 2025-12-29 00:14:54 +01:00
nym21 236b4097c5 computer: snapshot 2025-12-28 20:24:38 +01:00
nym21 f5790d5c8a computer: snapshot 2025-12-28 16:35:17 +01:00
nym21 f08ac7f916 computer: snapshot 2025-12-28 14:57:25 +01:00
nym21 e77d338357 computer: snapshot 2025-12-28 10:25:55 +01:00
nym21 5d6325ae30 computer: snapshot 2025-12-28 03:19:34 +01:00
nym21 9ba77dac0f global: snapshot 2025-12-27 20:34:13 +01:00
nym21 f9856cf0aa computer: fixes 2025-12-27 18:16:30 +01:00
nym21 de93f08e93 global: snapshot 2025-12-26 22:41:36 +01:00
nym21 d538280f4b modules: cleanup 2025-12-25 22:41:48 +01:00
nym21 bbb74b76c8 global: snapshot 2025-12-25 22:21:12 +01:00
nym21 eadf93b804 deps: upgrade 2025-12-24 15:13:43 +01:00
nym21 f29443fc15 server: openapi fixes 2025-12-23 20:23:40 +01:00
nym21 75a023bdd8 server: openapi fixes 2025-12-23 19:04:19 +01:00
nym21 d30344ee3c cleanup 2025-12-22 16:22:09 +01:00
nym21 02d635d48b cleanup 2025-12-21 23:55:45 +01:00
nym21 40ec356cc3 server: fix README 2025-12-21 23:28:03 +01:00
nym21 5a5d4da57d client: add dummy main 2025-12-21 23:23:18 +01:00
nym21 efb247d104 vecdb: bump 2025-12-21 23:20:48 +01:00
nym21 457b0e24c5 global: snapshot 2025-12-21 23:12:18 +01:00
nym21 6e0ac138d8 global: improve par writes 2025-12-21 16:22:25 +01:00
nym21 26c6c92bb8 dist: enable for brk_cli 2025-12-21 14:02:27 +01:00
nym21 e1ad45f44b scripts: update: also update rust-toolchain 2025-12-21 13:59:06 +01:00
nym21 aebca14d78 toolchain: set 2025-12-21 13:44:47 +01:00
nym21 42b0d7a174 scripts: improve publish 2025-12-21 13:37:58 +01:00
nym21 a37c2474fe bencher: publish = true 2025-12-21 13:29:59 +01:00
nym21 5f308e9da7 scripts: publish 2025-12-21 13:24:47 +01:00
nym21 3aadced85d release: v0.1.0-alpha.1 2025-12-21 13:08:40 +01:00
nym21 9375d5aded readmes: add perf section 2025-12-21 13:05:22 +01:00
nym21 2c8205146c benches: ignored 2025-12-21 12:47:28 +01:00
nym21 8d5a2b911d benches: added 2025-12-21 12:23:47 +01:00
nym21 7d5de7bf24 binder: snapshot 2025-12-21 01:23:05 +01:00
nym21 4b1410855a binder: snapshot 2025-12-21 01:04:13 +01:00
nym21 78a4d1af65 binder: snapshot 2025-12-21 00:42:54 +01:00
nym21 5e3519aad4 binder: snapshot 2025-12-21 00:33:56 +01:00
nym21 4386ef47fe binder: snapshot 2025-12-20 23:52:12 +01:00
nym21 135a18d56f binder: snapshot 2025-12-20 23:24:24 +01:00
nym21 71f45479b9 binder: snapshot 2025-12-20 21:08:17 +01:00
nym21 bcb8d5bed6 binder: snapshot 2025-12-20 19:33:04 +01:00
nym21 8f19bf7350 binder: snapshot 2025-12-20 18:19:48 +01:00
nym21 25860636f0 cargo: fix path to vecdb 2025-12-20 17:02:59 +01:00
nym21 8c2402cacb global: snapshot 2025-12-20 17:02:00 +01:00
nym21 4b910ceaa7 global: snapshot 2025-12-20 11:48:37 +01:00
nym21 4a0ce6337f global: snapshot 2025-12-20 10:16:06 +01:00
nym21 e134ed11a9 global: snapshot 2025-12-19 15:48:32 +01:00
nym21 03b83846ef global: snapshot 2025-12-19 15:25:48 +01:00
nym21 7c86c803fa changelog: update 2025-12-19 00:26:44 +01:00
1618 changed files with 763704 additions and 54384 deletions
+5
View File
@@ -0,0 +1,5 @@
[build]
rustflags = ["-C", "target-cpu=native"]
[alias]
dev = "run -p brk_cli --features brk_server/bindgen"
+15
View File
@@ -0,0 +1,15 @@
name: Check outdated dependencies
on:
schedule:
- cron: '0 9 * * *'
workflow_dispatch:
jobs:
outdated:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo install cargo-outdated
- run: cargo outdated --exit-code 1 --depth 1
+16
View File
@@ -6,12 +6,24 @@ target
websites/dist
bridge/
/ids.txt
rust_out
# Copies
*\ copy*
# Ignored
_*
!__*.py
/*.md
/*.py
/*.json
/*.html
/research
/filter_*
/heatmaps*
/oracle*
/playground
/*.txt
# Logs
*.log*
@@ -32,3 +44,7 @@ expand.rs
# Benchmarks
[0-9]/
/benches
# AI
.claude
-22
View File
@@ -1,22 +0,0 @@
{
"file_scan_exclusions": [
// default
"**/.git",
"**/.svn",
"**/.hg",
"**/.jj",
"**/CVS",
"**/.DS_Store",
"**/Thumbs.db",
"**/.classpath",
"**/.settings",
// custom
"**/lean-qr/*/index.mjs",
"**/modern-screenshot/*/index.mjs",
"**/solidjs-signals/*/dist/prod.js",
"uFuzzy.mjs",
"lightweight-charts.standalone.production.mjs"
// "scripts/packages",
// "dist"
]
}
Generated
+361 -2597
View File
File diff suppressed because it is too large Load Diff
+40 -39
View File
@@ -4,7 +4,7 @@ members = ["crates/*"]
package.description = "The Bitcoin Research Kit is a suite of tools designed to extract, compute and display data stored on a Bitcoin Core node"
package.license = "MIT"
package.edition = "2024"
package.version = "0.1.0-alpha.0"
package.version = "0.1.3"
package.homepage = "https://bitcoinresearchkit.org"
package.repository = "https://github.com/bitcoinresearchkit/brk"
package.readme = "README.md"
@@ -36,54 +36,55 @@ inherits = "release"
debug = true
[workspace.dependencies]
aide = { version = "0.16.0-alpha.1", features = ["axum-json", "axum-query"] }
axum = "0.8.7"
aide = { version = "0.16.0-alpha.2", features = ["axum-json", "axum-query"] }
axum = { version = "0.8.8", default-features = false, features = ["http1", "json", "query", "tokio", "tracing"] }
bitcoin = { version = "0.32.8", features = ["serde"] }
bitcoincore-rpc = "0.19.0"
brk_bencher = { version = "0.1.0-alpha.0", path = "crates/brk_bencher" }
brk_binder = { version = "0.1.0-alpha.0", path = "crates/brk_binder" }
brk_bundler = { version = "0.1.0-alpha.0", path = "crates/brk_bundler" }
brk_cli = { version = "0.1.0-alpha.0", path = "crates/brk_cli" }
brk_computer = { version = "0.1.0-alpha.0", path = "crates/brk_computer" }
brk_error = { version = "0.1.0-alpha.0", path = "crates/brk_error" }
brk_fetcher = { version = "0.1.0-alpha.0", path = "crates/brk_fetcher" }
brk_grouper = { version = "0.1.0-alpha.0", path = "crates/brk_grouper" }
brk_indexer = { version = "0.1.0-alpha.0", path = "crates/brk_indexer" }
brk_query = { version = "0.1.0-alpha.0", path = "crates/brk_query", features = ["tokio"] }
brk_iterator = { version = "0.1.0-alpha.0", path = "crates/brk_iterator" }
brk_logger = { version = "0.1.0-alpha.0", path = "crates/brk_logger" }
brk_mcp = { version = "0.1.0-alpha.0", path = "crates/brk_mcp" }
brk_mempool = { version = "0.1.0-alpha.0", path = "crates/brk_mempool" }
brk_reader = { version = "0.1.0-alpha.0", path = "crates/brk_reader" }
brk_rpc = { version = "0.1.0-alpha.0", path = "crates/brk_rpc" }
brk_server = { version = "0.1.0-alpha.0", path = "crates/brk_server" }
brk_store = { version = "0.1.0-alpha.0", path = "crates/brk_store" }
brk_types = { version = "0.1.0-alpha.0", path = "crates/brk_types" }
brk_traversable = { version = "0.1.0-alpha.0", path = "crates/brk_traversable", features = ["pco", "derive"] }
brk_traversable_derive = { version = "0.1.0-alpha.0", path = "crates/brk_traversable_derive" }
byteview = "0.9.1"
brk_alloc = { version = "0.1.3", path = "crates/brk_alloc" }
brk_bencher = { version = "0.1.3", path = "crates/brk_bencher" }
brk_bindgen = { version = "0.1.3", path = "crates/brk_bindgen" }
brk_cli = { version = "0.1.3", path = "crates/brk_cli" }
brk_client = { version = "0.1.3", path = "crates/brk_client" }
brk_cohort = { version = "0.1.3", path = "crates/brk_cohort" }
brk_computer = { version = "0.1.3", path = "crates/brk_computer" }
brk_error = { version = "0.1.3", path = "crates/brk_error" }
brk_fetcher = { version = "0.1.3", path = "crates/brk_fetcher" }
brk_indexer = { version = "0.1.3", path = "crates/brk_indexer" }
brk_iterator = { version = "0.1.3", path = "crates/brk_iterator" }
brk_logger = { version = "0.1.3", path = "crates/brk_logger" }
brk_mempool = { version = "0.1.3", path = "crates/brk_mempool" }
brk_query = { version = "0.1.3", path = "crates/brk_query", features = ["tokio"] }
brk_reader = { version = "0.1.3", path = "crates/brk_reader" }
brk_rpc = { version = "0.1.3", path = "crates/brk_rpc" }
brk_server = { version = "0.1.3", path = "crates/brk_server" }
brk_store = { version = "0.1.3", path = "crates/brk_store" }
brk_traversable = { version = "0.1.3", path = "crates/brk_traversable", features = ["pco", "derive"] }
brk_traversable_derive = { version = "0.1.3", path = "crates/brk_traversable_derive" }
brk_types = { version = "0.1.3", path = "crates/brk_types" }
brk_website = { version = "0.1.3", path = "crates/brk_website" }
byteview = "0.10.0"
color-eyre = "0.6.5"
derive_deref = "1.1.1"
fjall = "3.0.0-rc.6"
# fjall3 = { path = "../fjall3", package = "fjall" }
# fjall3 = { git = "https://github.com/fjall-rs/fjall.git", rev = "434979ef59d8fd2b36b91e6ff759a36d19a397ee", package = "fjall" }
jiff = "0.2.16"
log = "0.4.29"
mimalloc = { version = "0.1.48", features = ["v3"] }
minreq = { version = "2.14.1", features = ["https", "serde_json"] }
derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] }
fjall = "3.0.1"
indexmap = { version = "2.13.0", features = ["serde"] }
jiff = { version = "0.2.18", features = ["perf-inline", "tz-system"], default-features = false }
minreq = { version = "2.14.1", features = ["https", "json-using-serde"] }
owo-colors = "4.2.3"
parking_lot = "0.12.5"
rayon = "1.11.0"
rustc-hash = "2.1.1"
schemars = "1.1.0"
schemars = { version = "1.2.1", features = ["indexmap2"] }
serde = "1.0.228"
serde_bytes = "0.11.19"
serde_derive = "1.0.228"
serde_json = { version = "1.0.145", features = ["float_roundtrip"] }
serde_json = { version = "1.0.149", features = ["float_roundtrip", "preserve_order"] }
smallvec = "1.15.1"
tokio = { version = "1.48.0", features = ["rt-multi-thread"] }
vecdb = { version = "0.4.3", features = ["derive", "serde_json", "pco"] }
# vecdb = { path = "../anydb/crates/vecdb", features = ["derive", "serde_json", "pco"] }
# vecdb = { git = "https://github.com/anydb-rs/anydb", features = ["derive", "serde_json", "pco"] }
tokio = { version = "1.49.0", features = ["rt-multi-thread"] }
tracing = { version = "0.1", default-features = false, features = ["std"] }
tower-http = { version = "0.6.8", features = ["catch-panic", "compression-br", "compression-gzip", "compression-zstd", "cors", "normalize-path", "timeout", "trace"] }
tower-layer = "0.3"
vecdb = { version = "0.6.5", features = ["derive", "serde_json", "pco", "schemars"] }
# vecdb = { path = "../anydb/crates/vecdb", features = ["derive", "serde_json", "pco", "schemars"] }
[workspace.metadata.release]
shared-version = true
+9 -13
View File
@@ -6,21 +6,19 @@ homepage.workspace = true
repository.workspace = true
edition.workspace = true
version.workspace = true
build = "build.rs"
[features]
full = [
"bencher",
"binder",
"bundler",
"bindgen",
"client",
"computer",
"error",
"fetcher",
"grouper",
"cohort",
"indexer",
"iterator",
"logger",
"mcp",
"mempool",
"query",
"reader",
@@ -31,16 +29,15 @@ full = [
"types",
]
bencher = ["brk_bencher"]
binder = ["brk_binder"]
bundler = ["brk_bundler"]
bindgen = ["brk_bindgen"]
client = ["brk_client"]
computer = ["brk_computer"]
error = ["brk_error"]
fetcher = ["brk_fetcher"]
grouper = ["brk_grouper"]
cohort = ["brk_cohort"]
indexer = ["brk_indexer"]
iterator = ["brk_iterator"]
logger = ["brk_logger"]
mcp = ["brk_mcp"]
mempool = ["brk_mempool"]
query = ["brk_query"]
reader = ["brk_reader"]
@@ -52,16 +49,15 @@ types = ["brk_types"]
[dependencies]
brk_bencher = { workspace = true, optional = true }
brk_binder = { workspace = true, optional = true }
brk_bundler = { workspace = true, optional = true }
brk_bindgen = { workspace = true, optional = true }
brk_client = { workspace = true, optional = true }
brk_computer = { workspace = true, optional = true }
brk_error = { workspace = true, optional = true }
brk_fetcher = { workspace = true, optional = true }
brk_grouper = { workspace = true, optional = true }
brk_cohort = { workspace = true, optional = true }
brk_indexer = { workspace = true, optional = true }
brk_iterator = { workspace = true, optional = true }
brk_logger = { workspace = true, optional = true }
brk_mcp = { workspace = true, optional = true }
brk_mempool = { workspace = true, optional = true }
brk_query = { workspace = true, optional = true }
brk_reader = { workspace = true, optional = true }
+45 -26
View File
@@ -2,15 +2,15 @@
Umbrella crate for the Bitcoin Research Kit.
## What It Enables
Single dependency to access any BRK component. Enable only what you need via feature flags.
[crates.io](https://crates.io/crates/brk) | [docs.rs](https://docs.rs/brk)
## Usage
Single dependency to access any BRK component. Enable only what you need via feature flags.
```toml
[dependencies]
brk = { version = "0.x", features = ["query", "types"] }
brk = { version = "0.1", features = ["query", "types"] }
```
```rust,ignore
@@ -18,26 +18,45 @@ use brk::query::Query;
use brk::types::Height;
```
## Feature Flags
Feature flags match crate names without the `brk_` prefix. Use `full` to enable all.
| Feature | Crate | Description |
|---------|-------|-------------|
| `bencher` | `brk_bencher` | Resource monitoring |
| `binder` | `brk_binder` | Client code generation |
| `bundler` | `brk_bundler` | JS bundling |
| `computer` | `brk_computer` | Metric computation |
| `error` | `brk_error` | Error types |
| `fetcher` | `brk_fetcher` | Price data fetching |
| `grouper` | `brk_grouper` | Cohort filtering |
| `indexer` | `brk_indexer` | Blockchain indexing |
| `iterator` | `brk_iterator` | Block iteration |
| `logger` | `brk_logger` | Logging setup |
| `mcp` | `brk_mcp` | MCP server |
| `mempool` | `brk_mempool` | Mempool monitoring |
| `query` | `brk_query` | Query interface |
| `reader` | `brk_reader` | Raw block reading |
| `rpc` | `brk_rpc` | Bitcoin RPC client |
| `server` | `brk_server` | HTTP API server |
| `store` | `brk_store` | Key-value storage |
| `traversable` | `brk_traversable` | Data traversal |
| `types` | `brk_types` | Domain types |
## Crates
**Core Pipeline**
| Crate | Description |
|-------|-------------|
| [brk_reader](https://docs.rs/brk_reader) | Read blocks from `blk*.dat` with parallel parsing and XOR decoding |
| [brk_indexer](https://docs.rs/brk_indexer) | Index transactions, addresses, and UTXOs |
| [brk_computer](https://docs.rs/brk_computer) | Compute derived metrics (realized cap, MVRV, SOPR, cohorts, etc.) |
| [brk_mempool](https://docs.rs/brk_mempool) | Monitor mempool, estimate fees, project upcoming blocks |
| [brk_query](https://docs.rs/brk_query) | Query interface for indexed and computed data |
| [brk_server](https://docs.rs/brk_server) | REST API with OpenAPI docs |
**Data & Storage**
| Crate | Description |
|-------|-------------|
| [brk_types](https://docs.rs/brk_types) | Domain types: `Height`, `Sats`, `Txid`, addresses, etc. |
| [brk_store](https://docs.rs/brk_store) | Key-value storage (fjall wrapper) |
| [brk_fetcher](https://docs.rs/brk_fetcher) | Fetch price data from exchanges |
| [brk_rpc](https://docs.rs/brk_rpc) | Bitcoin Core RPC client |
| [brk_iterator](https://docs.rs/brk_iterator) | Unified block iteration with automatic source selection |
| [brk_cohort](https://docs.rs/brk_cohort) | UTXO and address cohort filtering |
| [brk_traversable](https://docs.rs/brk_traversable) | Navigate hierarchical data structures |
**Clients & Integration**
| Crate | Description |
|-------|-------------|
| [brk_client](https://docs.rs/brk_client) | Generated Rust API client |
| [brk_bindgen](https://docs.rs/brk_bindgen) | Generate typed clients (Rust, JavaScript, Python) |
**Internal**
| Crate | Description |
|-------|-------------|
| [brk_cli](https://docs.rs/brk_cli) | CLI binary (`cargo install --locked brk_cli`) |
| [brk_error](https://docs.rs/brk_error) | Error types |
| [brk_logger](https://docs.rs/brk_logger) | Logging infrastructure |
| [brk_bencher](https://docs.rs/brk_bencher) | Benchmarking utilities |
-8
View File
@@ -1,8 +0,0 @@
fn main() {
let profile = std::env::var("PROFILE").unwrap_or_default();
if profile == "release" {
println!("cargo:rustc-flag=-C");
println!("cargo:rustc-flag=target-cpu=native");
}
}
+8 -12
View File
@@ -4,13 +4,17 @@
#[doc(inline)]
pub use brk_bencher as bencher;
#[cfg(feature = "binder")]
#[cfg(feature = "bindgen")]
#[doc(inline)]
pub use brk_binder as binder;
pub use brk_bindgen as bindgen;
#[cfg(feature = "bundler")]
#[cfg(feature = "client")]
#[doc(inline)]
pub use brk_bundler as bundler;
pub use brk_client as client;
#[cfg(feature = "cohort")]
#[doc(inline)]
pub use brk_cohort as cohort;
#[cfg(feature = "computer")]
#[doc(inline)]
@@ -24,10 +28,6 @@ pub use brk_error as error;
#[doc(inline)]
pub use brk_fetcher as fetcher;
#[cfg(feature = "grouper")]
#[doc(inline)]
pub use brk_grouper as grouper;
#[cfg(feature = "indexer")]
#[doc(inline)]
pub use brk_indexer as indexer;
@@ -40,10 +40,6 @@ pub use brk_iterator as iterator;
#[doc(inline)]
pub use brk_logger as logger;
#[cfg(feature = "mcp")]
#[doc(inline)]
pub use brk_mcp as mcp;
#[cfg(feature = "mempool")]
#[doc(inline)]
pub use brk_mempool as mempool;
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "brk_alloc"
description = "Global allocator and memory utilities for brk"
version.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
[dependencies]
libmimalloc-sys = { version = "0.1.44", features = ["extended"] }
mimalloc = { version = "0.1.48", features = ["v3"] }
+21
View File
@@ -0,0 +1,21 @@
//! Global allocator and memory utilities for brk.
//!
//! This crate sets mimalloc as the global allocator and provides
//! utilities for monitoring and managing memory.
use mimalloc::MiMalloc as Allocator;
#[global_allocator]
static GLOBAL: Allocator = Allocator;
/// Mimalloc allocator utilities
pub struct Mimalloc;
impl Mimalloc {
/// Eagerly free memory back to OS.
/// Only call at natural pause points.
#[inline]
pub fn collect() {
unsafe { libmimalloc_sys::mi_collect(true) }
}
}
-1
View File
@@ -6,7 +6,6 @@ edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
build = "build.rs"
[dependencies]
brk_error = { workspace = true }
-8
View File
@@ -1,8 +0,0 @@
fn main() {
let profile = std::env::var("PROFILE").unwrap_or_default();
if profile == "release" {
println!("cargo:rustc-flag=-C");
println!("cargo:rustc-flag=target-cpu=native");
}
}
+8 -6
View File
@@ -1,9 +1,11 @@
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{self, Write};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use std::{
collections::HashMap,
fs::{self, File},
io::{self, Write},
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
time::SystemTime,
};
pub struct DiskMonitor {
cache: HashMap<PathBuf, (u64, SystemTime)>, // path -> (bytes_used, mtime)
+5 -3
View File
@@ -1,6 +1,8 @@
use std::fs::File;
use std::io::{self, Write};
use std::path::Path;
use std::{
fs::File,
io::{self, Write},
path::Path,
};
#[cfg(target_os = "linux")]
use std::fs;
+5 -3
View File
@@ -1,6 +1,8 @@
use std::fs::File;
use std::io::{self, Write};
use std::path::Path;
use std::{
fs::File,
io::{self, Write},
path::Path,
};
#[cfg(target_os = "linux")]
use std::fs;
+1 -1
View File
@@ -6,7 +6,7 @@ edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
build = "build.rs"
publish = false
[dependencies]
plotters = "0.3.7"
-8
View File
@@ -1,8 +0,0 @@
fn main() {
let profile = std::env::var("PROFILE").unwrap_or_default();
if profile == "release" {
println!("cargo:rustc-flag=-C");
println!("cargo:rustc-flag=target-cpu=native");
}
}
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "brk_binder"
description = "A generator of binding files for other languages"
version.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
build = "build.rs"
[dependencies]
brk_query = { workspace = true }
brk_types = { workspace = true }
-42
View File
@@ -1,42 +0,0 @@
# brk_binder
Code generation for BRK client libraries.
## What It Enables
Generate typed metric catalogs and constants for JavaScript/TypeScript clients. Keeps frontend code in sync with available metrics without manual maintenance.
## Key Features
- **Metric catalog**: Generates `metrics.js` with all metric IDs and their supported indexes
- **Compression**: Metric names compressed via word-to-base62 mapping for smaller bundles
- **Mining pools**: Generates `pools.js` with pool ID to name mapping
- **Version sync**: Generates `version.js` matching server version
## Core API
```rust,ignore
generate_js_files(&query, &modules_path)?;
```
## Generated Files
```
modules/brk-client/generated/
├── version.js # export const VERSION = "vX.Y.Z"
├── metrics.js # INDEXES, COMPRESSED_METRIC_TO_INDEXES
└── pools.js # POOL_ID_TO_POOL_NAME
```
## Metric Compression
To minimize bundle size, metric names are compressed:
1. Extract all words from metric names
2. Sort by frequency
3. Map to base52 codes (A-Z, a-z)
4. Store compressed metric → index group mapping
## Built On
- `brk_query` for metric enumeration
- `brk_types` for mining pool data
-8
View File
@@ -1,8 +0,0 @@
fn main() {
let profile = std::env::var("PROFILE").unwrap_or_default();
if profile == "release" {
println!("cargo:rustc-flag=-C");
println!("cargo:rustc-flag=target-cpu=native");
}
}
-240
View File
@@ -1,240 +0,0 @@
use std::{
collections::{BTreeMap, HashMap},
fs, io,
path::Path,
};
use brk_query::Query;
use brk_types::{Index, pools};
use super::VERSION;
const AUTO_GENERATED_DISCLAIMER: &str = "//
// File auto-generated, any modifications will be overwritten
//";
pub fn generate_js_files(query: &Query, modules_path: &Path) -> io::Result<()> {
let path = modules_path.join("brk-client");
if !fs::exists(&path)? {
return Ok(());
}
let path = path.join("generated");
fs::create_dir_all(&path)?;
generate_version_file(&path)?;
generate_metrics_file(query, &path)?;
generate_pools_file(&path)
}
fn generate_version_file(parent: &Path) -> io::Result<()> {
let path = parent.join(Path::new("version.js"));
let contents = format!(
"{AUTO_GENERATED_DISCLAIMER}
export const VERSION = \"v{VERSION}\";
"
);
fs::write(path, contents)
}
fn generate_pools_file(parent: &Path) -> io::Result<()> {
let path = parent.join(Path::new("pools.js"));
let pools = pools();
let mut contents = format!("{AUTO_GENERATED_DISCLAIMER}\n");
contents += "
/**
* @typedef {typeof POOL_ID_TO_POOL_NAME} PoolIdToPoolName
* @typedef {keyof PoolIdToPoolName} PoolId
*/
export const POOL_ID_TO_POOL_NAME = /** @type {const} */ ({
";
let mut sorted_pools: Vec<_> = pools.iter().collect();
sorted_pools.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
contents += &sorted_pools
.iter()
.map(|pool| {
let slug = pool.slug();
format!(" {slug}: \"{}\",", pool.name)
})
.collect::<Vec<_>>()
.join("\n");
contents += "\n});\n";
fs::write(path, contents)
}
fn generate_metrics_file(query: &Query, parent: &Path) -> io::Result<()> {
let path = parent.join(Path::new("metrics.js"));
let indexes = Index::all();
let mut contents = format!(
"{AUTO_GENERATED_DISCLAIMER}
export const INDEXES = /** @type {{const}} */ ([
{}
]);
/**
* @typedef {{typeof INDEXES[number]}} IndexName
*/
",
indexes
.iter()
.map(|i| format!(" \"{}\"", i.serialize_long()))
.collect::<Vec<_>>()
.join(",\n")
);
// contents += &indexes
// .iter()
// .map(|i| format!(" * @typedef {{\"{}\"}} {i}", i.serialize_long()))
// .collect::<Vec<_>>()
// .join("\n");
// contents += &format!(
// "
// * @typedef {{{}}} Index
// */
// ",
// indexes
// .iter()
// .map(|i| i.to_string())
// .collect::<Vec<_>>()
// .join(" | ")
// );
let mut unique_index_groups = BTreeMap::new();
let mut word_to_freq: BTreeMap<_, usize> = BTreeMap::new();
query.metric_to_index_to_vec().keys().for_each(|metric| {
metric.split("_").for_each(|word| {
*word_to_freq.entry(word).or_default() += 1;
});
});
let mut word_to_freq = word_to_freq.into_iter().collect::<Vec<_>>();
word_to_freq.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
let words = word_to_freq
.into_iter()
.map(|(str, _)| str)
.collect::<Vec<_>>();
contents += &format!(
"
export const INDEX_TO_WORD = [
{}
];
",
words
.iter()
.enumerate()
.map(|(index, word)| format!("\"{word}\", // {}", index_to_letters(index)))
.collect::<Vec<_>>()
.join("\n ")
);
let word_to_base62 = words
.into_iter()
.enumerate()
.map(|(i, w)| (w, index_to_letters(i)))
.collect::<HashMap<_, _>>();
let mut ser_metric_to_indexes = "
/** @type {Record<string, IndexName[]>} */
export const COMPRESSED_METRIC_TO_INDEXES = {
"
.to_string();
query
.metric_to_index_to_vec()
.iter()
.for_each(|(metric, index_to_vec)| {
let indexes = index_to_vec
.keys()
.map(|i| format!("\"{}\"", i.serialize_long()))
.collect::<Vec<_>>()
.join(", ");
let indexes = format!("[{indexes}]");
let unique = unique_index_groups.len();
let index = index_to_letters(*unique_index_groups.entry(indexes).or_insert(unique));
let compressed_metric = metric.split('_').fold(String::new(), |mut acc, w| {
if !acc.is_empty() {
acc.push('_');
}
acc.push_str(&word_to_base62[w]);
acc
});
ser_metric_to_indexes += &format!(" {compressed_metric}: {index},\n");
});
ser_metric_to_indexes += "};
";
let mut sorted_groups: Vec<_> = unique_index_groups.into_iter().collect();
sorted_groups.sort_by_key(|(_, index)| *index);
sorted_groups.into_iter().for_each(|(group, index)| {
let index = index_to_letters(index);
contents += &format!("/** @type {{IndexName[]}} */\nconst {index} = {group};\n");
});
contents += &ser_metric_to_indexes;
fs::write(path, contents)
}
fn index_to_letters(mut index: usize) -> String {
if index < 52 {
return (index_to_char(index) as char).to_string();
}
let mut result = [0u8; 8];
let mut pos = 8;
loop {
pos -= 1;
result[pos] = index_to_char(index % 52);
index /= 52;
if index == 0 {
break;
}
index -= 1;
}
unsafe { String::from_utf8_unchecked(result[pos..].to_vec()) }
}
fn index_to_char(index: usize) -> u8 {
match index {
0..=25 => b'A' + index as u8,
26..=51 => b'a' + (index - 26) as u8,
_ => unreachable!(),
}
}
// fn letters_to_index(s: &str) -> usize {
// let mut result = 0;
// for byte in s.bytes() {
// let value = char_to_index(byte) as usize;
// result = result * 52 + value + 1;
// }
// result - 1
// }
// fn char_to_index(byte: u8) -> u8 {
// match byte {
// b'A'..=b'Z' => byte - b'A',
// b'a'..=b'z' => byte - b'a' + 26,
// _ => 255, // Invalid
// }
// }
-5
View File
@@ -1,5 +0,0 @@
mod js;
pub use js::*;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
-1
View File
@@ -1 +0,0 @@
// TODO ?
View File
+6
View File
@@ -0,0 +1,6 @@
clients/
/*.json
/*.js
/*.rs
/*.py
tests/output/
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "brk_bindgen"
description = "A trait-based generator of client bindings for multiple languages"
version.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
[dependencies]
brk_cohort = { workspace = true }
brk_query = { workspace = true }
brk_types = { workspace = true }
indexmap = { workspace = true }
oas3 = "0.20"
serde = { workspace = true }
serde_json = { workspace = true }
+46
View File
@@ -0,0 +1,46 @@
# brk_bindgen
Code generation for BRK client libraries.
## What It Enables
Generate typed client libraries for Rust, JavaScript, and Python from the OpenAPI specification. Keeps frontend code in sync with available metrics and API endpoints without manual maintenance.
## Key Features
- **Multi-language**: Generates Rust, JavaScript, and Python clients
- **OpenAPI-driven**: Extracts endpoints and schemas from the OpenAPI spec
- **Metric catalog**: Includes all metric IDs and their supported indexes
- **Type definitions**: Generates types/interfaces from JSON Schema
- **Selective output**: Generate only the languages you need
## Core API
```rust,ignore
use brk_bindgen::{generate_clients, ClientOutputPaths};
let paths = ClientOutputPaths::new()
.rust("crates/brk_client/src/lib.rs")
.javascript("modules/brk-client/index.js")
.python("packages/brk_client/brk_client/__init__.py");
generate_clients(&vecs, &openapi_json, &paths)?;
```
## Generated Clients
| Language | Contents |
|----------|----------|
| Rust | Typed API client using `brk_types`, metric catalog |
| JavaScript | ES module with JSDoc types, metric catalog, fetch helpers |
| Python | Typed client with dataclasses, metric catalog |
Each client includes:
- All REST API endpoints as typed functions
- Complete metric catalog with index information
- Type definitions for request/response schemas
## Built On
- `brk_query` for metric enumeration
- `brk_types` for type schemas
+14
View File
@@ -0,0 +1,14 @@
//! Analysis module for name deconstruction and pattern detection.
//!
//! This module implements bottom-up analysis of vec names to detect
//! common denominators (prefixes/suffixes) and field positions.
mod names;
mod patterns;
mod positions;
mod tree;
pub use names::*;
pub use patterns::*;
pub use positions::*;
pub use tree::*;
+195
View File
@@ -0,0 +1,195 @@
//! Common prefix/suffix detection for metric names.
//!
//! This module provides utilities to find common prefixes and suffixes
//! among metric names, which is used to detect pattern mode (suffix vs prefix).
/// Find the longest common prefix among all strings.
/// Returns the prefix WITH trailing underscore if found at word boundary.
/// Returns None if no common prefix exists.
pub fn find_common_prefix(names: &[&str]) -> Option<String> {
if names.is_empty() || names.iter().any(|n| n.is_empty()) {
return None;
}
let first = names[0];
// Find character-by-character common prefix
let mut prefix_len = 0;
for (i, ch) in first.chars().enumerate() {
if names.iter().all(|n| n.chars().nth(i) == Some(ch)) {
prefix_len = i + 1;
} else {
break;
}
}
if prefix_len == 0 {
return None;
}
let raw_prefix = &first[..prefix_len];
// Must end at underscore boundary for semantic coherence
if raw_prefix.ends_with('_') {
return Some(raw_prefix.to_string());
}
// If raw_prefix equals one of the full names (one name is a prefix of all others),
// return it with trailing underscore for proper base detection
if names.contains(&raw_prefix) {
return Some(format!("{}_", raw_prefix));
}
// Find the last underscore position
if let Some(last_underscore) = raw_prefix.rfind('_') {
let clean_prefix = &first[..=last_underscore];
if names.iter().all(|n| n.starts_with(clean_prefix)) {
return Some(clean_prefix.to_string());
}
}
None
}
/// Find the longest common suffix among all strings.
/// Returns the suffix WITH leading underscore if found at word boundary.
/// Returns None if no common suffix exists.
pub fn find_common_suffix(names: &[&str]) -> Option<String> {
if names.is_empty() || names.iter().any(|n| n.is_empty()) {
return None;
}
let first = names[0];
let first_chars: Vec<char> = first.chars().collect();
// Find character-by-character common suffix (from the end)
let mut suffix_len = 0;
for i in 0..first_chars.len() {
let idx_from_end = first_chars.len() - 1 - i;
let ch = first_chars[idx_from_end];
let all_match = names.iter().all(|n| {
let n_chars: Vec<char> = n.chars().collect();
if i >= n_chars.len() {
return false;
}
n_chars[n_chars.len() - 1 - i] == ch
});
if all_match {
suffix_len = i + 1;
} else {
break;
}
}
if suffix_len == 0 {
return None;
}
let raw_suffix = &first[first.len() - suffix_len..];
// Must start at underscore boundary for semantic coherence
if raw_suffix.starts_with('_') {
return Some(raw_suffix.to_string());
}
// Check if preceded by underscore in all names (word boundary)
let at_word_boundary = names.iter().all(|n| {
if *n == raw_suffix {
true // Suffix is the whole string
} else if let Some(prefix) = n.strip_suffix(raw_suffix) {
prefix.ends_with('_')
} else {
false
}
});
if at_word_boundary {
return Some(format!("_{}", raw_suffix));
}
// Find the first underscore position in suffix
if let Some(first_underscore) = raw_suffix.find('_') {
let clean_suffix = &raw_suffix[first_underscore..];
if names.iter().all(|n| n.ends_with(clean_suffix)) {
return Some(clean_suffix.to_string());
}
}
None
}
/// Normalize a prefix string by ensuring it ends with underscore.
/// Returns empty string if input is empty.
pub fn normalize_prefix(s: &str) -> String {
if s.is_empty() {
String::new()
} else if s.ends_with('_') {
s.to_string()
} else {
format!("{}_", s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_common_prefix_basic() {
let names = vec!["addrs_0sats", "addrs_1sats", "addrs_2sats"];
assert_eq!(find_common_prefix(&names), Some("addrs_".to_string()));
}
#[test]
fn test_common_prefix_none() {
let names = vec!["foo", "bar", "baz"];
assert_eq!(find_common_prefix(&names), None);
}
#[test]
fn test_common_prefix_lth() {
let names = vec!["lth_cost_basis_max", "lth_cost_basis_min", "lth_cost_basis"];
assert_eq!(find_common_prefix(&names), Some("lth_cost_basis_".to_string()));
}
#[test]
fn test_common_suffix_basic() {
let names = vec!["cumulative_supply", "net_supply", "total_supply"];
assert_eq!(find_common_suffix(&names), Some("_supply".to_string()));
}
#[test]
fn test_common_prefix_cost_basis() {
// With suffix naming convention, cost_basis variants share a common prefix
let names = vec!["cost_basis_max", "cost_basis_min", "cost_basis"];
assert_eq!(find_common_prefix(&names), Some("cost_basis_".to_string()));
}
#[test]
fn test_common_suffix_none() {
let names = vec!["foo", "bar", "baz"];
assert_eq!(find_common_suffix(&names), None);
}
#[test]
fn test_common_prefix_one_is_prefix_of_other() {
// When one name is a prefix of another (block_count vs block_count_cumulative)
let names = vec!["block_count_cumulative", "block_count"];
assert_eq!(find_common_prefix(&names), Some("block_count_".to_string()));
}
#[test]
fn test_common_suffix_realized_loss() {
let names = vec![
"cumulative_realized_loss",
"net_realized_loss",
"realized_loss",
];
assert_eq!(
find_common_suffix(&names),
Some("_realized_loss".to_string())
);
}
}
+369
View File
@@ -0,0 +1,369 @@
//! Structural pattern detection using bottom-up analysis.
//!
//! This module detects repeating tree structures and analyzes them
//! using the bottom-up name deconstruction algorithm.
use std::collections::{BTreeMap, BTreeSet};
use brk_types::{TreeNode, extract_json_type};
use super::analyze_pattern_modes;
use crate::{PatternBaseResult, PatternField, StructuralPattern, to_pascal_case};
/// Context for pattern detection, holding all intermediate state.
struct PatternContext {
/// Maps field signatures to pattern names
signature_to_pattern: BTreeMap<Vec<PatternField>, String>,
/// Counts how many times each signature appears
signature_counts: BTreeMap<Vec<PatternField>, usize>,
/// Maps normalized signatures to pattern names (for naming consistency)
normalized_to_name: BTreeMap<Vec<PatternField>, String>,
/// Counts pattern name usage (for unique naming)
name_counts: BTreeMap<String, usize>,
/// Maps signatures to their child field lists
signature_to_child_fields: BTreeMap<Vec<PatternField>, Vec<Vec<PatternField>>>,
}
impl PatternContext {
fn new() -> Self {
Self {
signature_to_pattern: BTreeMap::new(),
signature_counts: BTreeMap::new(),
normalized_to_name: BTreeMap::new(),
name_counts: BTreeMap::new(),
signature_to_child_fields: BTreeMap::new(),
}
}
}
/// Detect structural patterns in the tree using a bottom-up approach.
///
/// Returns (patterns, concrete_to_pattern, concrete_to_type_param, node_bases).
/// Each pattern has its `mode` set based on analysis of all instances.
/// `node_bases` maps tree paths to their computed PatternBaseResult for use during generation.
pub fn detect_structural_patterns(
tree: &TreeNode,
) -> (
Vec<StructuralPattern>,
BTreeMap<Vec<PatternField>, String>,
BTreeMap<Vec<PatternField>, String>,
BTreeMap<String, PatternBaseResult>,
) {
let mut ctx = PatternContext::new();
resolve_branch_patterns(tree, &mut ctx);
let (generic_patterns, generic_mappings, type_mappings) =
detect_generic_patterns(&ctx.signature_to_pattern);
// Only include patterns that appear 2+ times for the patterns list
let mut patterns: Vec<StructuralPattern> = ctx
.signature_to_pattern
.iter()
.filter(|(sig, _)| {
ctx.signature_counts.get(*sig).copied().unwrap_or(0) >= 2
&& !generic_mappings.contains_key(*sig)
})
.map(|(fields, name)| {
let child_fields_list = ctx.signature_to_child_fields.get(fields);
let fields_with_type_params = fields
.iter()
.enumerate()
.map(|(i, f)| {
let type_param = child_fields_list
.and_then(|list| list.get(i))
.and_then(|cf| type_mappings.get(cf).cloned());
PatternField {
type_param,
..f.clone()
}
})
.collect();
StructuralPattern {
name: name.clone(),
fields: fields_with_type_params,
mode: None, // Will be determined by analyze_pattern_modes
is_generic: false,
}
})
.collect();
// Deduplicate patterns by name - different signatures can map to the same name
// when their normalized forms match but they can't be unified as generics
{
let mut seen_names: BTreeSet<String> = BTreeSet::new();
patterns.retain(|p| seen_names.insert(p.name.clone()));
}
patterns.extend(generic_patterns);
// Build pattern lookup for mode analysis (patterns appearing 2+ times)
let mut pattern_lookup: BTreeMap<Vec<PatternField>, String> = BTreeMap::new();
for (sig, name) in &ctx.signature_to_pattern {
if ctx.signature_counts.get(sig).copied().unwrap_or(0) >= 2 {
pattern_lookup.insert(sig.clone(), name.clone());
}
}
pattern_lookup.extend(generic_mappings.clone());
let concrete_to_pattern = pattern_lookup.clone();
// Analyze pattern modes (suffix vs prefix) from all instances
// Also collects node bases for each tree path
let node_bases = analyze_pattern_modes(tree, &mut patterns, &pattern_lookup);
patterns.sort_by(|a, b| b.fields.len().cmp(&a.fields.len()));
(patterns, concrete_to_pattern, type_mappings, node_bases)
}
/// Detect generic patterns by grouping signatures by their normalized form.
fn detect_generic_patterns(
signature_to_pattern: &BTreeMap<Vec<PatternField>, String>,
) -> (
Vec<StructuralPattern>,
BTreeMap<Vec<PatternField>, String>,
BTreeMap<Vec<PatternField>, String>,
) {
let mut normalized_groups: BTreeMap<
Vec<PatternField>,
Vec<(Vec<PatternField>, String, String)>,
> = BTreeMap::new();
for (fields, name) in signature_to_pattern {
if let Some((normalized, extracted_type)) = normalize_fields_for_generic(fields) {
normalized_groups.entry(normalized).or_default().push((
fields.clone(),
name.clone(),
extracted_type,
));
}
}
let mut patterns = Vec::new();
let mut pattern_mappings: BTreeMap<Vec<PatternField>, String> = BTreeMap::new();
let mut type_mappings: BTreeMap<Vec<PatternField>, String> = BTreeMap::new();
for (normalized_fields, group) in normalized_groups {
if group.len() >= 2 {
let generic_name = group[0].1.clone();
for (concrete_fields, _, extracted_type) in &group {
pattern_mappings.insert(concrete_fields.clone(), generic_name.clone());
type_mappings.insert(concrete_fields.clone(), extracted_type.clone());
}
patterns.push(StructuralPattern {
name: generic_name,
fields: normalized_fields,
mode: None, // Will be determined by analyze_pattern_modes
is_generic: true,
});
}
}
(patterns, pattern_mappings, type_mappings)
}
/// Normalize fields by replacing concrete value types with "T".
///
/// Handles two cases:
/// 1. All leaves have identical types (e.g., all `Sats`) -> normalize to `T`
/// 2. All leaves have wrapper types with the same inner type (e.g., `Open<Sats>`, `High<Sats>`)
/// -> normalize to `Open<T>`, `High<T>`, etc.
fn normalize_fields_for_generic(fields: &[PatternField]) -> Option<(Vec<PatternField>, String)> {
let leaf_types: Vec<&str> = fields
.iter()
.filter(|f| f.is_leaf())
.map(|f| f.rust_type.as_str())
.collect();
if leaf_types.is_empty() {
return None;
}
let first_type = leaf_types[0];
// Case 1: All leaf types are identical
if leaf_types.iter().all(|t| *t == first_type) {
let normalized = fields
.iter()
.map(|f| {
if f.is_branch() {
f.clone()
} else {
PatternField {
name: f.name.clone(),
rust_type: "T".to_string(),
json_type: "T".to_string(),
indexes: f.indexes.clone(),
type_param: None,
}
}
})
.collect();
return Some((normalized, crate::extract_inner_type(first_type)));
}
// Case 2: Check if all leaves have wrapper types with the same inner type
// e.g., Open<Sats>, High<Sats>, Low<Sats>, Close<Sats> all have inner type Sats
let inner_types: Vec<String> = leaf_types
.iter()
.map(|t| crate::extract_inner_type(t))
.collect();
let first_inner = &inner_types[0];
// Only proceed if inner types differ from originals (meaning they had wrappers)
// and all inner types are the same
if inner_types.iter().all(|t| t == first_inner)
&& inner_types
.iter()
.zip(leaf_types.iter())
.any(|(inner, orig)| inner != *orig)
{
let normalized = fields
.iter()
.map(|f| {
if f.is_branch() {
f.clone()
} else {
PatternField {
name: f.name.clone(),
rust_type: replace_inner_type(&f.rust_type, "T"),
json_type: replace_inner_type(&f.json_type, "T"),
indexes: f.indexes.clone(),
type_param: None,
}
}
})
.collect();
return Some((normalized, first_inner.clone()));
}
None
}
/// Replace the inner type of a wrapper generic with a new type.
/// e.g., `Open<Sats>` with replacement `T` -> `Open<T>`
fn replace_inner_type(type_str: &str, replacement: &str) -> String {
if let Some(start) = type_str.find('<')
&& let Some(end) = type_str.rfind('>')
&& start < end
{
format!("{}<{}>", &type_str[..start], replacement)
} else {
replacement.to_string()
}
}
/// Recursively resolve branch patterns bottom-up.
fn resolve_branch_patterns(
node: &TreeNode,
ctx: &mut PatternContext,
) -> Option<(String, Vec<PatternField>)> {
let TreeNode::Branch(children) = node else {
return None;
};
// Convert to sorted BTreeMap for consistent pattern detection
let sorted_children: BTreeMap<_, _> = children.iter().collect();
let mut fields: Vec<PatternField> = Vec::new();
let mut child_fields_vec: Vec<Vec<PatternField>> = Vec::new();
for (child_name, child_node) in sorted_children {
let (rust_type, json_type, indexes, child_fields) = match child_node {
TreeNode::Leaf(leaf) => (
leaf.kind().to_string(),
extract_json_type(&leaf.schema),
leaf.indexes().clone(),
Vec::new(),
),
TreeNode::Branch(_) => {
let (pattern_name, child_pattern_fields) = resolve_branch_patterns(child_node, ctx)
.unwrap_or_else(|| ("Unknown".to_string(), Vec::new()));
(
pattern_name.clone(),
pattern_name,
BTreeSet::new(),
child_pattern_fields,
)
}
};
fields.push(PatternField {
name: child_name.clone(),
rust_type,
json_type,
indexes,
type_param: None,
});
child_fields_vec.push(child_fields);
}
// Fields are already sorted since we iterated over BTreeMap
*ctx.signature_counts.entry(fields.clone()).or_insert(0) += 1;
ctx.signature_to_child_fields
.entry(fields.clone())
.or_insert(child_fields_vec);
let pattern_name = if let Some(existing) = ctx.signature_to_pattern.get(&fields) {
existing.clone()
} else {
let normalized = normalize_fields_for_naming(&fields);
// Generate stable name from first word of each field (deduped, sorted)
let first_words: BTreeSet<String> = fields
.iter()
.filter_map(|f| f.name.split('_').next())
.map(to_pascal_case)
.collect();
let combined: String = first_words.into_iter().collect();
let name = ctx
.normalized_to_name
.entry(normalized)
.or_insert_with(|| generate_pattern_name(&combined, &mut ctx.name_counts))
.clone();
ctx.signature_to_pattern
.insert(fields.clone(), name.clone());
name
};
Some((pattern_name, fields))
}
/// Normalize fields for naming (same structure = same name).
fn normalize_fields_for_naming(fields: &[PatternField]) -> Vec<PatternField> {
fields
.iter()
.map(|f| {
if f.is_branch() {
f.clone()
} else {
PatternField {
name: f.name.clone(),
rust_type: "_".to_string(),
json_type: "_".to_string(),
indexes: f.indexes.clone(),
type_param: None,
}
}
})
.collect()
}
/// Generate a unique pattern name.
fn generate_pattern_name(field_name: &str, name_counts: &mut BTreeMap<String, usize>) -> String {
let pascal = to_pascal_case(field_name);
let sanitized = if pascal.chars().next().is_some_and(|c| c.is_ascii_digit()) {
format!("_{}", pascal)
} else {
pascal
};
let base_name = format!("{}Pattern", sanitized);
let count = name_counts.entry(base_name.clone()).or_insert(0);
*count += 1;
if *count == 1 {
base_name
} else {
format!("{}{}", base_name, count)
}
}
@@ -0,0 +1,468 @@
//! Pattern mode detection and field part extraction.
//!
//! This module analyzes pattern instances to detect whether they use
//! suffix mode (fields append to acc) or prefix mode (fields prepend to acc),
//! and extracts the field parts (relatives or prefixes) for code generation.
use std::collections::BTreeMap;
use brk_types::TreeNode;
use super::{find_common_prefix, find_common_suffix, get_node_fields, normalize_prefix};
use crate::{PatternBaseResult, PatternField, PatternMode, StructuralPattern, build_child_path};
/// Result of analyzing a single pattern instance.
#[derive(Debug, Clone)]
struct InstanceAnalysis {
/// The base to return to parent (used for nesting)
base: String,
/// For suffix mode: field -> relative name
/// For prefix mode: field -> prefix
field_parts: BTreeMap<String, String>,
/// Whether this instance appears to be suffix mode
is_suffix_mode: bool,
}
/// Analyze all pattern instances and determine their modes.
///
/// This is the main entry point for mode detection. It processes
/// the tree bottom-up, collecting analysis for each pattern instance,
/// then determines the consistent mode for each pattern.
///
/// Returns a map from tree paths to their computed PatternBaseResult.
/// This map is used during generation to check pattern compatibility.
pub fn analyze_pattern_modes(
tree: &TreeNode,
patterns: &mut [StructuralPattern],
pattern_lookup: &BTreeMap<Vec<PatternField>, String>,
) -> BTreeMap<String, PatternBaseResult> {
// Collect analyses from all instances, keyed by pattern name
let mut all_analyses: BTreeMap<String, Vec<InstanceAnalysis>> = BTreeMap::new();
// Also collect base results for each node, keyed by tree path
let mut node_bases: BTreeMap<String, PatternBaseResult> = BTreeMap::new();
// Bottom-up traversal
collect_instance_analyses(tree, "", pattern_lookup, &mut all_analyses, &mut node_bases);
// For each pattern, determine mode from collected instances
for pattern in patterns.iter_mut() {
if let Some(analyses) = all_analyses.get(&pattern.name) {
pattern.mode = determine_pattern_mode(analyses, &pattern.fields);
}
}
node_bases
}
/// Recursively collect instance analyses bottom-up.
/// Returns the "base" for this node (used by parent for its analysis).
///
/// Also stores the PatternBaseResult for each node in `node_bases`, keyed by path.
fn collect_instance_analyses(
node: &TreeNode,
path: &str,
pattern_lookup: &BTreeMap<Vec<PatternField>, String>,
all_analyses: &mut BTreeMap<String, Vec<InstanceAnalysis>>,
node_bases: &mut BTreeMap<String, PatternBaseResult>,
) -> Option<String> {
match node {
TreeNode::Leaf(leaf) => {
// Leaves return their metric name as the base
Some(leaf.name().to_string())
}
TreeNode::Branch(children) => {
// First, process all children recursively (bottom-up)
let mut child_bases: BTreeMap<String, String> = BTreeMap::new();
for (field_name, child_node) in children {
let child_path = build_child_path(path, field_name);
if let Some(base) = collect_instance_analyses(
child_node,
&child_path,
pattern_lookup,
all_analyses,
node_bases,
) {
child_bases.insert(field_name.clone(), base);
}
}
if child_bases.is_empty() {
return None;
}
// Analyze this instance
let analysis = analyze_instance(&child_bases);
// Store the base result for this node
// Note: has_outlier is false because we use recursive base computation
// which gives correct bases without needing outlier detection
node_bases.insert(
path.to_string(),
PatternBaseResult {
base: analysis.base.clone(),
has_outlier: false,
is_suffix_mode: analysis.is_suffix_mode,
field_parts: analysis.field_parts.clone(),
},
);
// Get the pattern name for this node (if any)
let fields = get_node_fields(children, pattern_lookup);
if let Some(pattern_name) = pattern_lookup.get(&fields) {
all_analyses
.entry(pattern_name.clone())
.or_default()
.push(analysis.clone());
}
// Return the base for parent
Some(analysis.base)
}
}
}
/// Analyze a single pattern instance from its child bases.
fn analyze_instance(child_bases: &BTreeMap<String, String>) -> InstanceAnalysis {
let bases: Vec<&str> = child_bases.values().map(|s| s.as_str()).collect();
// Try suffix mode first: look for common prefix among children
if let Some(common_prefix) = find_common_prefix(&bases) {
let base = common_prefix.trim_end_matches('_').to_string();
let mut field_parts = BTreeMap::new();
for (field_name, child_base) in child_bases {
// Relative = child_base with common prefix stripped
// If child_base equals base, relative is empty (identity field)
let relative = if child_base == &base {
String::new()
} else {
child_base
.strip_prefix(&common_prefix)
.unwrap_or(child_base)
.to_string()
};
field_parts.insert(field_name.clone(), relative);
}
return InstanceAnalysis {
base,
field_parts,
is_suffix_mode: true,
};
}
// Try prefix mode: look for common suffix among children
if let Some(common_suffix) = find_common_suffix(&bases) {
let base = common_suffix.trim_start_matches('_').to_string();
let mut field_parts = BTreeMap::new();
for (field_name, child_base) in child_bases {
// Prefix = child_base with common suffix stripped, normalized to end with _
let prefix = child_base
.strip_suffix(&common_suffix)
.map(normalize_prefix)
.unwrap_or_default();
field_parts.insert(field_name.clone(), prefix);
}
return InstanceAnalysis {
base,
field_parts,
is_suffix_mode: false,
};
}
// No common prefix or suffix - use empty base so _m(base, relative) returns just the relative.
// This handles cases like utxo_cohorts.all.activity where children have completely
// different bases (coinblocks_destroyed, coindays_destroyed, etc.)
let field_parts = child_bases
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
InstanceAnalysis {
base: String::new(),
field_parts,
is_suffix_mode: true,
}
}
/// Determine the consistent mode for a pattern from all its instances.
/// Uses majority voting: if most instances agree on mode and field_parts,
/// use those. Minority instances will be inlined at usage sites.
fn determine_pattern_mode(
analyses: &[InstanceAnalysis],
fields: &[PatternField],
) -> Option<PatternMode> {
if analyses.is_empty() {
return None;
}
// Group instances by (mode, field_parts) signature
let suffix_instances: Vec<_> = analyses.iter().filter(|a| a.is_suffix_mode).collect();
let prefix_instances: Vec<_> = analyses.iter().filter(|a| !a.is_suffix_mode).collect();
// Pick the majority mode group
let (majority_instances, is_suffix) = if suffix_instances.len() >= prefix_instances.len() {
(suffix_instances, true)
} else {
(prefix_instances, false)
};
if majority_instances.is_empty() {
return None;
}
// Find the most common field_parts within the majority group
// Convert to sorted Vec for comparison since BTreeMap isn't hashable
let mut parts_counts: BTreeMap<Vec<(String, String)>, usize> = BTreeMap::new();
for analysis in &majority_instances {
let mut sorted: Vec<_> = analysis
.field_parts
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
sorted.sort();
*parts_counts.entry(sorted).or_insert(0) += 1;
}
let (best_parts_vec, _count) = parts_counts.into_iter().max_by_key(|(_, count)| *count)?;
let best_parts: BTreeMap<String, String> = best_parts_vec.into_iter().collect();
// Verify all required fields have parts
for field in fields {
if !best_parts.contains_key(&field.name) {
return None;
}
}
let field_parts = best_parts;
if is_suffix {
Some(PatternMode::Suffix {
relatives: field_parts,
})
} else {
Some(PatternMode::Prefix {
prefixes: field_parts,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_analyze_instance_suffix_mode() {
let mut child_bases = BTreeMap::new();
child_bases.insert("max".to_string(), "lth_cost_basis_max".to_string());
child_bases.insert("min".to_string(), "lth_cost_basis_min".to_string());
child_bases.insert("percentiles".to_string(), "lth_cost_basis".to_string());
let analysis = analyze_instance(&child_bases);
assert!(analysis.is_suffix_mode);
assert_eq!(analysis.base, "lth_cost_basis");
assert_eq!(analysis.field_parts.get("max"), Some(&"max".to_string()));
assert_eq!(analysis.field_parts.get("min"), Some(&"min".to_string()));
assert_eq!(
analysis.field_parts.get("percentiles"),
Some(&"".to_string())
);
}
#[test]
fn test_analyze_instance_prefix_mode() {
// Period-prefixed metrics like "1y_lump_sum_stack", "1m_lump_sum_stack"
// share a common suffix "_lump_sum_stack" with different period prefixes
let mut child_bases = BTreeMap::new();
child_bases.insert("_1y".to_string(), "1y_lump_sum_stack".to_string());
child_bases.insert("_1m".to_string(), "1m_lump_sum_stack".to_string());
child_bases.insert("_1w".to_string(), "1w_lump_sum_stack".to_string());
let analysis = analyze_instance(&child_bases);
assert!(!analysis.is_suffix_mode);
assert_eq!(analysis.base, "lump_sum_stack");
assert_eq!(analysis.field_parts.get("_1y"), Some(&"1y_".to_string()));
assert_eq!(analysis.field_parts.get("_1m"), Some(&"1m_".to_string()));
assert_eq!(analysis.field_parts.get("_1w"), Some(&"1w_".to_string()));
}
#[test]
fn test_analyze_instance_root_suffix() {
// At root level with suffix naming convention
let mut child_bases = BTreeMap::new();
child_bases.insert("max".to_string(), "cost_basis_max".to_string());
child_bases.insert("min".to_string(), "cost_basis_min".to_string());
child_bases.insert("percentiles".to_string(), "cost_basis".to_string());
let analysis = analyze_instance(&child_bases);
// With suffix naming, common prefix is "cost_basis_" (since cost_basis is one of the names)
assert!(analysis.is_suffix_mode);
assert_eq!(analysis.base, "cost_basis");
assert_eq!(analysis.field_parts.get("max"), Some(&"max".to_string()));
assert_eq!(analysis.field_parts.get("min"), Some(&"min".to_string()));
assert_eq!(
analysis.field_parts.get("percentiles"),
Some(&"".to_string())
);
}
#[test]
fn test_determine_pattern_mode_majority_voting() {
// Test that majority voting works when instances have mixed modes.
// This simulates CostBasisPattern2: most instances use suffix mode,
// but root-level uses prefix mode (max_cost_basis, min_cost_basis, cost_basis).
use std::collections::BTreeSet;
let fields = vec![
PatternField {
name: "max".to_string(),
rust_type: "TestType".to_string(),
json_type: "number".to_string(),
indexes: BTreeSet::new(),
type_param: None,
},
PatternField {
name: "min".to_string(),
rust_type: "TestType".to_string(),
json_type: "number".to_string(),
indexes: BTreeSet::new(),
type_param: None,
},
PatternField {
name: "percentiles".to_string(),
rust_type: "TestType".to_string(),
json_type: "number".to_string(),
indexes: BTreeSet::new(),
type_param: None,
},
];
// 3 suffix mode instances (majority)
let suffix1 = InstanceAnalysis {
base: "lth_cost_basis".to_string(),
field_parts: [
("max".to_string(), "max".to_string()),
("min".to_string(), "min".to_string()),
("percentiles".to_string(), "".to_string()),
]
.into_iter()
.collect(),
is_suffix_mode: true,
};
let suffix2 = InstanceAnalysis {
base: "sth_cost_basis".to_string(),
field_parts: [
("max".to_string(), "max".to_string()),
("min".to_string(), "min".to_string()),
("percentiles".to_string(), "".to_string()),
]
.into_iter()
.collect(),
is_suffix_mode: true,
};
let suffix3 = InstanceAnalysis {
base: "utxo_cost_basis".to_string(),
field_parts: [
("max".to_string(), "max".to_string()),
("min".to_string(), "min".to_string()),
("percentiles".to_string(), "".to_string()),
]
.into_iter()
.collect(),
is_suffix_mode: true,
};
// 1 prefix mode instance (minority - root level)
let prefix1 = InstanceAnalysis {
base: "cost_basis".to_string(),
field_parts: [
("max".to_string(), "max_".to_string()),
("min".to_string(), "min_".to_string()),
("percentiles".to_string(), "".to_string()),
]
.into_iter()
.collect(),
is_suffix_mode: false,
};
let analyses = vec![suffix1, suffix2, suffix3, prefix1];
let mode = determine_pattern_mode(&analyses, &fields);
// Should pick suffix mode (majority) with the common field_parts
assert!(mode.is_some());
match mode.unwrap() {
PatternMode::Suffix { relatives } => {
assert_eq!(relatives.get("max"), Some(&"max".to_string()));
assert_eq!(relatives.get("min"), Some(&"min".to_string()));
assert_eq!(relatives.get("percentiles"), Some(&"".to_string()));
}
PatternMode::Prefix { .. } => {
panic!("Expected suffix mode, got prefix mode");
}
}
}
#[test]
fn test_determine_pattern_mode_all_same() {
// Test when all instances agree on mode and field_parts
use std::collections::BTreeSet;
let fields = vec![
PatternField {
name: "max".to_string(),
rust_type: "TestType".to_string(),
json_type: "number".to_string(),
indexes: BTreeSet::new(),
type_param: None,
},
PatternField {
name: "min".to_string(),
rust_type: "TestType".to_string(),
json_type: "number".to_string(),
indexes: BTreeSet::new(),
type_param: None,
},
];
let instance1 = InstanceAnalysis {
base: "metric_a".to_string(),
field_parts: [
("max".to_string(), "max".to_string()),
("min".to_string(), "min".to_string()),
]
.into_iter()
.collect(),
is_suffix_mode: true,
};
let instance2 = InstanceAnalysis {
base: "metric_b".to_string(),
field_parts: [
("max".to_string(), "max".to_string()),
("min".to_string(), "min".to_string()),
]
.into_iter()
.collect(),
is_suffix_mode: true,
};
let analyses = vec![instance1, instance2];
let mode = determine_pattern_mode(&analyses, &fields);
assert!(mode.is_some());
match mode.unwrap() {
PatternMode::Suffix { relatives } => {
assert_eq!(relatives.get("max"), Some(&"max".to_string()));
assert_eq!(relatives.get("min"), Some(&"min".to_string()));
}
PatternMode::Prefix { .. } => {
panic!("Expected suffix mode");
}
}
}
}
+573
View File
@@ -0,0 +1,573 @@
//! Tree traversal helpers for pattern analysis.
//!
//! This module provides utilities for working with the TreeNode structure,
//! including leaf name extraction and index pattern detection.
use std::collections::{BTreeMap, BTreeSet};
use brk_types::{Index, TreeNode, extract_json_type};
use indexmap::IndexMap;
use crate::{IndexSetPattern, PatternField, child_type_name};
use super::{find_common_prefix, find_common_suffix, normalize_prefix};
/// Get the shortest leaf name from a tree node.
///
/// This is useful for pattern base analysis where we want the "base" case
/// (e.g., the leaf without suffix like `_btc` or `_usd`).
fn get_shortest_leaf_name(node: &TreeNode) -> Option<String> {
match node {
TreeNode::Leaf(leaf) => Some(leaf.name().to_string()),
TreeNode::Branch(children) => children
.values()
.filter_map(get_shortest_leaf_name)
.min_by_key(|name| name.len()),
}
}
/// Get the field signature for a branch node's children.
/// Fields are sorted alphabetically for consistent pattern matching.
pub fn get_node_fields(
children: &IndexMap<String, TreeNode>,
pattern_lookup: &BTreeMap<Vec<PatternField>, String>,
) -> Vec<PatternField> {
let mut fields: Vec<PatternField> = children
.iter()
.map(|(name, node)| {
let (rust_type, json_type, indexes) = match node {
TreeNode::Leaf(leaf) => (
leaf.kind().to_string(),
extract_json_type(&leaf.schema),
leaf.indexes().clone(),
),
TreeNode::Branch(grandchildren) => {
let child_fields = get_node_fields(grandchildren, pattern_lookup);
let pattern_name = pattern_lookup
.get(&child_fields)
.cloned()
.unwrap_or_else(|| "Unknown".to_string());
(pattern_name.clone(), pattern_name, BTreeSet::new())
}
};
PatternField {
name: name.clone(),
rust_type,
json_type,
indexes,
type_param: None,
}
})
.collect();
// Sort for consistent pattern matching (display order preserved in IndexMap)
fields.sort_by(|a, b| a.name.cmp(&b.name));
fields
}
/// Detect index patterns (sets of indexes that appear together on metrics).
pub fn detect_index_patterns(tree: &TreeNode) -> Vec<IndexSetPattern> {
let mut unique_index_sets: BTreeSet<BTreeSet<Index>> = BTreeSet::new();
collect_index_sets_from_tree(tree, &mut unique_index_sets);
// Sort by count (descending) then by first index name for deterministic ordering
let mut sorted_sets: Vec<_> = unique_index_sets
.into_iter()
.filter(|indexes| !indexes.is_empty())
.collect();
sorted_sets.sort_by(|a, b| {
b.len()
.cmp(&a.len())
.then_with(|| a.iter().next().cmp(&b.iter().next()))
});
// Assign unique sequential names
sorted_sets
.into_iter()
.enumerate()
.map(|(i, indexes)| IndexSetPattern {
name: format!("MetricPattern{}", i + 1),
indexes,
})
.collect()
}
fn collect_index_sets_from_tree(
node: &TreeNode,
unique_index_sets: &mut BTreeSet<BTreeSet<Index>>,
) {
match node {
TreeNode::Leaf(leaf) => {
unique_index_sets.insert(leaf.indexes().clone());
}
TreeNode::Branch(children) => {
for child in children.values() {
collect_index_sets_from_tree(child, unique_index_sets);
}
}
}
}
/// Result of analyzing a pattern instance's base.
#[derive(Debug, Clone)]
pub struct PatternBaseResult {
/// The computed base name for the pattern.
pub base: String,
/// Whether an outlier child was excluded to find the pattern.
/// If true, pattern factory should not be used.
pub has_outlier: bool,
/// Whether this instance uses suffix mode (common prefix) or prefix mode (common suffix).
/// Used to check compatibility with the pattern's mode.
pub is_suffix_mode: bool,
/// The field parts (suffix in suffix mode, prefix in prefix mode) for each field.
/// Used to check if instance field parts match the pattern's field parts.
pub field_parts: BTreeMap<String, String>,
}
impl PatternBaseResult {
/// Create a default result that forces inlining (has_outlier = true).
/// Use when no pattern base could be computed during lookup.
pub fn force_inline() -> Self {
Self {
base: String::new(),
has_outlier: true,
is_suffix_mode: true,
field_parts: BTreeMap::new(),
}
}
/// Create an empty result with no outlier.
/// Use for root-level patterns or when children have no common pattern.
pub fn empty() -> Self {
Self {
base: String::new(),
has_outlier: false,
is_suffix_mode: true,
field_parts: BTreeMap::new(),
}
}
}
/// Get the metric base for a pattern instance by analyzing direct children.
///
/// Uses the shortest leaf names from direct children to find common prefix/suffix.
///
/// If the initial analysis fails to find a common pattern, it tries excluding
/// each child one at a time to detect outliers (e.g., a mismatched "base" field
/// from indexer/computed tree merging).
///
/// Returns both the base and whether an outlier was detected.
pub fn get_pattern_instance_base(node: &TreeNode) -> PatternBaseResult {
let child_names = get_direct_children_for_analysis(node);
if child_names.is_empty() {
return PatternBaseResult::empty();
}
// Try to find common base from leaf names
if let Some(result) = try_find_base(&child_names, false) {
return PatternBaseResult {
base: result.base,
has_outlier: result.has_outlier,
is_suffix_mode: result.is_suffix_mode,
field_parts: result.field_parts,
};
}
// If no common pattern found and we have enough children, try excluding outliers
if child_names.len() > 2 {
for i in 0..child_names.len() {
let filtered: Vec<_> = child_names
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(_, v)| v.clone())
.collect();
if let Some(result) = try_find_base(&filtered, true) {
return PatternBaseResult {
base: result.base,
has_outlier: true,
is_suffix_mode: result.is_suffix_mode,
field_parts: result.field_parts,
};
}
}
}
// Fallback: no common prefix/suffix found - this is a root-level pattern
// Return empty base so metric names are used directly
PatternBaseResult::empty()
}
/// Result of try_find_base: base name, has_outlier flag, is_suffix_mode flag, and field_parts.
struct FindBaseResult {
base: String,
has_outlier: bool,
is_suffix_mode: bool,
field_parts: BTreeMap<String, String>,
}
/// Try to find a common base from child names using prefix/suffix detection.
/// Returns Some(FindBaseResult) if found.
fn try_find_base(
child_names: &[(String, String)],
is_outlier_attempt: bool,
) -> Option<FindBaseResult> {
let leaf_names: Vec<&str> = child_names.iter().map(|(_, n)| n.as_str()).collect();
// Try common prefix first (suffix mode)
if let Some(prefix) = find_common_prefix(&leaf_names) {
let base = prefix.trim_end_matches('_').to_string();
let mut field_parts = BTreeMap::new();
for (field_name, leaf_name) in child_names {
// Compute the suffix part for this field
let suffix = if leaf_name == &base {
String::new()
} else {
leaf_name
.strip_prefix(&prefix)
.unwrap_or(leaf_name)
.to_string()
};
field_parts.insert(field_name.clone(), suffix);
}
return Some(FindBaseResult {
base,
has_outlier: is_outlier_attempt,
is_suffix_mode: true,
field_parts,
});
}
// Try common suffix (prefix mode)
if let Some(suffix) = find_common_suffix(&leaf_names) {
let base = suffix.trim_start_matches('_').to_string();
let mut field_parts = BTreeMap::new();
for (field_name, leaf_name) in child_names {
// Compute the prefix part for this field, normalized to end with _
let prefix_part = leaf_name
.strip_suffix(&suffix)
.map(normalize_prefix)
.unwrap_or_default();
field_parts.insert(field_name.clone(), prefix_part);
}
return Some(FindBaseResult {
base,
has_outlier: is_outlier_attempt,
is_suffix_mode: false,
field_parts,
});
}
None
}
/// Get (field_name, shortest_leaf_name) pairs for direct children of a branch node.
///
/// Uses the shortest leaf name from each child subtree to find the "base" case
/// (the leaf without suffix modifiers like `_btc` or `_usd`).
fn get_direct_children_for_analysis(node: &TreeNode) -> Vec<(String, String)> {
match node {
TreeNode::Leaf(leaf) => vec![(leaf.name().to_string(), leaf.name().to_string())],
TreeNode::Branch(children) => children
.iter()
.filter_map(|(field_name, child)| {
get_shortest_leaf_name(child).map(|leaf_name| (field_name.clone(), leaf_name))
})
.collect(),
}
}
/// Infer the accumulated name for a child node based on a descendant leaf name.
pub fn infer_accumulated_name(parent_acc: &str, field_name: &str, descendant_leaf: &str) -> String {
if let Some(pos) = descendant_leaf.find(field_name) {
if pos == 0 {
return field_name.to_string();
}
if pos > 0 && descendant_leaf.chars().nth(pos - 1) == Some('_') {
return if parent_acc.is_empty() {
field_name.to_string()
} else {
format!("{}_{}", parent_acc, field_name)
};
}
}
if parent_acc.is_empty() {
field_name.to_string()
} else {
format!("{}_{}", parent_acc, field_name)
}
}
/// Get fields with child field information for generic pattern lookup.
pub fn get_fields_with_child_info(
children: &IndexMap<String, TreeNode>,
parent_name: &str,
pattern_lookup: &BTreeMap<Vec<PatternField>, String>,
) -> Vec<(PatternField, Option<Vec<PatternField>>)> {
children
.iter()
.map(|(name, node)| {
let (rust_type, json_type, indexes, child_fields) = match node {
TreeNode::Leaf(leaf) => (
leaf.kind().to_string(),
extract_json_type(&leaf.schema),
leaf.indexes().clone(),
None,
),
TreeNode::Branch(grandchildren) => {
let child_fields = get_node_fields(grandchildren, pattern_lookup);
let pattern_name = pattern_lookup
.get(&child_fields)
.cloned()
.unwrap_or_else(|| child_type_name(parent_name, name));
(
pattern_name.clone(),
pattern_name,
BTreeSet::new(),
Some(child_fields),
)
}
};
(
PatternField {
name: name.clone(),
rust_type,
json_type,
indexes,
type_param: None,
},
child_fields,
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use brk_types::{MetricLeaf, MetricLeafWithSchema, TreeNode};
fn make_leaf(name: &str) -> TreeNode {
let leaf = MetricLeaf {
name: name.to_string(),
kind: "TestType".to_string(),
indexes: BTreeSet::new(),
};
TreeNode::Leaf(MetricLeafWithSchema::new(leaf, serde_json::json!({})))
}
fn make_branch(children: Vec<(&str, TreeNode)>) -> TreeNode {
let map: IndexMap<String, TreeNode> = children
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
TreeNode::Branch(map)
}
#[test]
fn test_get_pattern_instance_base_with_base_field() {
// Simulates vbytes tree: has base field with block_vbytes leaf
let tree = make_branch(vec![
(
"base",
make_branch(vec![("dateindex", make_leaf("block_vbytes"))]),
),
(
"average",
make_branch(vec![("dateindex", make_leaf("block_vbytes_average"))]),
),
(
"sum",
make_branch(vec![("dateindex", make_leaf("block_vbytes_sum"))]),
),
]);
let result = get_pattern_instance_base(&tree);
assert_eq!(result.base, "block_vbytes");
assert!(!result.has_outlier);
}
#[test]
fn test_get_pattern_instance_base_without_base_field() {
// Simulates weight tree: NO base field, only suffixed metrics
let tree = make_branch(vec![
(
"average",
make_branch(vec![("dateindex", make_leaf("block_weight_average"))]),
),
(
"sum",
make_branch(vec![("dateindex", make_leaf("block_weight_sum"))]),
),
(
"cumulative",
make_branch(vec![("dateindex", make_leaf("block_weight_cumulative"))]),
),
(
"max",
make_branch(vec![("dateindex", make_leaf("block_weight_max"))]),
),
(
"min",
make_branch(vec![("dateindex", make_leaf("block_weight_min"))]),
),
]);
let result = get_pattern_instance_base(&tree);
assert_eq!(result.base, "block_weight");
assert!(!result.has_outlier);
}
#[test]
fn test_get_pattern_instance_base_with_duplicate_base_field() {
// What if there's a "base" field that points to the same leaf as "average"?
// This could happen if the tree generation creates a base field that shares leaves with average
let tree = make_branch(vec![
(
"base",
make_branch(vec![("dateindex", make_leaf("block_weight_average"))]),
),
(
"average",
make_branch(vec![("dateindex", make_leaf("block_weight_average"))]),
),
(
"sum",
make_branch(vec![("dateindex", make_leaf("block_weight_sum"))]),
),
]);
let result = get_pattern_instance_base(&tree);
// Common prefix among all children is "block_weight_"
assert_eq!(result.base, "block_weight");
assert!(!result.has_outlier);
}
#[test]
fn test_get_pattern_instance_base_with_mismatched_base_name() {
// Simulates the actual bug: indexed tree's "base" field has name "weight"
// but computed tree's derived metrics use "block_weight_*" prefix.
// After tree merge, we get a base field with mismatched naming.
let tree = make_branch(vec![
("base", make_leaf("weight")), // Outlier - doesn't match pattern
("average", make_leaf("block_weight_average")),
("sum", make_leaf("block_weight_sum")),
("cumulative", make_leaf("block_weight_cumulative")),
("max", make_leaf("block_weight_max")),
("min", make_leaf("block_weight_min")),
]);
let result = get_pattern_instance_base(&tree);
// Should detect "weight" as outlier and find common prefix from others
assert_eq!(result.base, "block_weight");
assert!(result.has_outlier); // Pattern factory should NOT be used
}
#[test]
fn test_get_pattern_instance_base_root_level_no_common_pattern() {
// Simulates root-level pattern with metrics that have no common prefix/suffix.
// These names have no shared prefix or suffix, even when excluding any one.
// In this case, we should return empty base so metric names are used directly.
let tree = make_branch(vec![
("alpha", make_leaf("foo_metric")),
("beta", make_leaf("bar_value")),
("gamma", make_leaf("baz_count")),
]);
let result = get_pattern_instance_base(&tree);
// No common prefix or suffix - return empty base
assert_eq!(result.base, "");
assert!(!result.has_outlier);
}
#[test]
fn test_get_pattern_instance_base_two_children_no_pattern() {
// Two children with no common pattern - should still return empty base
let tree = make_branch(vec![
("foo", make_leaf("alpha")),
("bar", make_leaf("beta")),
]);
let result = get_pattern_instance_base(&tree);
assert_eq!(result.base, "");
assert!(!result.has_outlier);
}
#[test]
fn test_get_pattern_instance_base_with_outlier_excluded() {
// Simulates the realized pattern: adjusted_sopr, sopr, asopr.
// When "asopr" is excluded as outlier, "adjusted_sopr" and "sopr" share suffix "_sopr".
// The outlier detection should find base="sopr" with has_outlier=true.
let tree = make_branch(vec![
("adjustedSopr", make_leaf("adjusted_sopr")),
("sopr", make_leaf("sopr")),
("asopr", make_leaf("asopr")),
]);
let result = get_pattern_instance_base(&tree);
// Outlier detected - pattern base found by excluding "asopr"
assert_eq!(result.base, "sopr");
assert!(result.has_outlier); // Pattern factory should NOT be used (inline instead)
}
#[test]
fn test_get_pattern_instance_base_suffix_mode_price_ago() {
// Simulates price_ago pattern: price_1d_ago, price_1w_ago, price_10y_ago
// Common prefix is "price_", so this is suffix mode
let tree = make_branch(vec![
("_1d", make_leaf("price_1d_ago")),
("_1w", make_leaf("price_1w_ago")),
("_1m", make_leaf("price_1m_ago")),
("_10y", make_leaf("price_10y_ago")),
]);
let result = get_pattern_instance_base(&tree);
assert_eq!(result.base, "price");
assert!(result.is_suffix_mode); // Suffix mode: _m(base, "1d_ago")
assert!(!result.has_outlier);
}
#[test]
fn test_get_pattern_instance_base_prefix_mode_price_returns() {
// Simulates price_returns pattern: 1d_price_returns, 1w_price_returns, 10y_price_returns
// Common suffix is "_price_returns", so this is prefix mode
let tree = make_branch(vec![
("_1d", make_leaf("1d_price_returns")),
("_1w", make_leaf("1w_price_returns")),
("_1m", make_leaf("1m_price_returns")),
("_10y", make_leaf("10y_price_returns")),
]);
let result = get_pattern_instance_base(&tree);
assert_eq!(result.base, "price_returns");
assert!(!result.is_suffix_mode); // Prefix mode: _p("1d_", base)
assert!(!result.has_outlier);
}
#[test]
fn test_mode_detection_distinguishes_similar_structures() {
// Two patterns with identical structure but different naming conventions
// should have different modes detected
// Suffix mode pattern
let suffix_tree = make_branch(vec![
("_1y", make_leaf("lump_sum_1y")),
("_2y", make_leaf("lump_sum_2y")),
("_5y", make_leaf("lump_sum_5y")),
]);
let suffix_result = get_pattern_instance_base(&suffix_tree);
assert_eq!(suffix_result.base, "lump_sum");
assert!(suffix_result.is_suffix_mode);
// Prefix mode pattern (same structure, different naming)
let prefix_tree = make_branch(vec![
("_1y", make_leaf("1y_returns")),
("_2y", make_leaf("2y_returns")),
("_5y", make_leaf("5y_returns")),
]);
let prefix_result = get_pattern_instance_base(&prefix_tree);
assert_eq!(prefix_result.base, "returns");
assert!(!prefix_result.is_suffix_mode);
}
}
@@ -0,0 +1,62 @@
//! JavaScript language syntax implementation.
use crate::{GenericSyntax, LanguageSyntax, to_camel_case};
/// JavaScript-specific code generation syntax.
pub struct JavaScriptSyntax;
impl LanguageSyntax for JavaScriptSyntax {
fn field_name(&self, name: &str) -> String {
to_camel_case(name)
}
fn path_expr(&self, base_var: &str, suffix: &str) -> String {
// Convert base_var to camelCase for JavaScript
let var_name = to_camel_case(base_var);
format!("`${{{}}}{}`", var_name, suffix)
}
fn suffix_expr(&self, acc_var: &str, relative: &str) -> String {
let var_name = to_camel_case(acc_var);
if relative.is_empty() {
// Identity: just return acc
var_name
} else {
// _m(acc, relative) -> acc ? `${acc}_relative` : 'relative'
format!("_m({}, '{}')", var_name, relative)
}
}
fn prefix_expr(&self, prefix: &str, acc_var: &str) -> String {
let var_name = to_camel_case(acc_var);
if prefix.is_empty() {
// Identity: just return acc
var_name
} else {
// _p(prefix, acc) -> acc ? `${prefix}${acc}` : 'prefix_without_underscore'
let prefix_base = prefix.trim_end_matches('_');
format!("_p('{}', {})", prefix_base, var_name)
}
}
fn constructor(&self, type_name: &str, path_expr: &str) -> String {
format!("create{}(client, {})", type_name, path_expr)
}
fn field_init(&self, indent: &str, name: &str, _type_ann: &str, value: &str) -> String {
// JavaScript uses object literal syntax; type is in JSDoc, not in assignment
format!("{}{}: {},", indent, name, value)
}
fn generic_syntax(&self) -> GenericSyntax {
GenericSyntax::JAVASCRIPT
}
fn string_literal(&self, value: &str) -> String {
format!("'{}'", value)
}
fn constructor_name(&self, type_name: &str) -> String {
format!("create{}", type_name)
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Language-specific syntax backends.
//!
//! This module contains implementations of the `LanguageSyntax` trait
//! for each supported target language.
mod javascript;
mod python;
mod rust;
pub use javascript::JavaScriptSyntax;
pub use python::PythonSyntax;
pub use rust::RustSyntax;
+57
View File
@@ -0,0 +1,57 @@
//! Python language syntax implementation.
use crate::{GenericSyntax, LanguageSyntax, escape_python_keyword, to_snake_case};
/// Python-specific code generation syntax.
pub struct PythonSyntax;
impl LanguageSyntax for PythonSyntax {
fn field_name(&self, name: &str) -> String {
escape_python_keyword(&to_snake_case(name))
}
fn path_expr(&self, base_var: &str, suffix: &str) -> String {
format!("f'{{{}}}{}'", base_var, suffix)
}
fn suffix_expr(&self, acc_var: &str, relative: &str) -> String {
if relative.is_empty() {
// Identity: just return acc
acc_var.to_string()
} else {
// _m(acc, relative) -> f'{acc}_{relative}' if acc else 'relative'
format!("_m({}, '{}')", acc_var, relative)
}
}
fn prefix_expr(&self, prefix: &str, acc_var: &str) -> String {
if prefix.is_empty() {
// Identity: just return acc
acc_var.to_string()
} else {
// _p(prefix, acc) -> f'{prefix}{acc}' if acc else 'prefix_base'
let prefix_base = prefix.trim_end_matches('_');
format!("_p('{}', {})", prefix_base, acc_var)
}
}
fn constructor(&self, type_name: &str, path_expr: &str) -> String {
format!("{}(client, {})", type_name, path_expr)
}
fn field_init(&self, indent: &str, name: &str, type_ann: &str, value: &str) -> String {
format!("{}self.{}: {} = {}", indent, name, type_ann, value)
}
fn generic_syntax(&self) -> GenericSyntax {
GenericSyntax::PYTHON
}
fn string_literal(&self, value: &str) -> String {
format!("'{}'", value)
}
fn constructor_name(&self, type_name: &str) -> String {
type_name.to_string()
}
}
+58
View File
@@ -0,0 +1,58 @@
//! Rust language syntax implementation.
use crate::{GenericSyntax, LanguageSyntax, to_snake_case};
/// Rust-specific code generation syntax.
pub struct RustSyntax;
impl LanguageSyntax for RustSyntax {
fn field_name(&self, name: &str) -> String {
to_snake_case(name)
}
fn path_expr(&self, base_var: &str, suffix: &str) -> String {
format!("format!(\"{{{}}}{}\")", base_var, suffix)
}
fn suffix_expr(&self, acc_var: &str, relative: &str) -> String {
if relative.is_empty() {
// Identity: just return acc
format!("{}.clone()", acc_var)
} else {
// _m(&acc, relative) -> if acc.is_empty() { relative } else { format!("{acc}_{relative}") }
format!("_m(&{}, \"{}\")", acc_var, relative)
}
}
fn prefix_expr(&self, prefix: &str, acc_var: &str) -> String {
if prefix.is_empty() {
// Identity: just return acc
format!("{}.clone()", acc_var)
} else {
// _p(prefix, &acc) -> if acc.is_empty() { prefix_base } else { format!("{prefix}{acc}") }
let prefix_base = prefix.trim_end_matches('_');
format!("_p(\"{}\", &{})", prefix_base, acc_var)
}
}
fn constructor(&self, type_name: &str, path_expr: &str) -> String {
format!("{}::new(client.clone(), {})", type_name, path_expr)
}
fn field_init(&self, indent: &str, name: &str, _type_ann: &str, value: &str) -> String {
// Rust struct initialization; type is in struct definition, not in init
format!("{}{}: {},", indent, name, value)
}
fn generic_syntax(&self) -> GenericSyntax {
GenericSyntax::RUST
}
fn string_literal(&self, value: &str) -> String {
format!("\"{}\".to_string()", value)
}
fn constructor_name(&self, type_name: &str) -> String {
format!("{}::new", type_name)
}
}
@@ -0,0 +1,87 @@
//! Shared constant generation for static client data.
//!
//! Extracts common logic for generating INDEXES, POOL_ID_TO_POOL_NAME,
//! and cohort name constants across JavaScript and Python clients.
use std::collections::BTreeMap;
use brk_cohort::{
AGE_RANGE_NAMES, AMOUNT_RANGE_NAMES, EPOCH_NAMES, GE_AMOUNT_NAMES, LT_AMOUNT_NAMES,
MAX_AGE_NAMES, MIN_AGE_NAMES, SPENDABLE_TYPE_NAMES, TERM_NAMES, YEAR_NAMES,
};
use brk_types::{pools, Index, PoolSlug};
use serde::Serialize;
use serde_json::Value;
use crate::{to_camel_case, VERSION};
/// Collected constant data for client generation.
pub struct ClientConstants {
pub version: String,
pub indexes: Vec<&'static str>,
pub pool_map: BTreeMap<PoolSlug, &'static str>,
}
impl ClientConstants {
/// Collect all constant data.
pub fn collect() -> Self {
let indexes = Index::all();
let indexes: Vec<&'static str> = indexes.iter().map(|i| i.serialize_long()).collect();
let pools = pools();
let mut sorted_pools: Vec<_> = pools.iter().collect();
sorted_pools.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
let pool_map: BTreeMap<PoolSlug, &'static str> =
sorted_pools.iter().map(|p| (p.slug(), p.name)).collect();
Self {
version: format!("v{}", VERSION),
indexes,
pool_map,
}
}
}
/// Cohort name constants - shared data definitions.
pub struct CohortConstants;
impl CohortConstants {
/// Get all cohort constants as name-value pairs for iteration.
pub fn all() -> Vec<(&'static str, Value)> {
fn to_value<T: Serialize>(v: &T) -> Value {
serde_json::to_value(v).unwrap()
}
vec![
("TERM_NAMES", to_value(&TERM_NAMES)),
("EPOCH_NAMES", to_value(&EPOCH_NAMES)),
("YEAR_NAMES", to_value(&YEAR_NAMES)),
("SPENDABLE_TYPE_NAMES", to_value(&SPENDABLE_TYPE_NAMES)),
("AGE_RANGE_NAMES", to_value(&AGE_RANGE_NAMES)),
("MAX_AGE_NAMES", to_value(&MAX_AGE_NAMES)),
("MIN_AGE_NAMES", to_value(&MIN_AGE_NAMES)),
("AMOUNT_RANGE_NAMES", to_value(&AMOUNT_RANGE_NAMES)),
("GE_AMOUNT_NAMES", to_value(&GE_AMOUNT_NAMES)),
("LT_AMOUNT_NAMES", to_value(&LT_AMOUNT_NAMES)),
]
}
}
/// Convert top-level keys of a JSON object to camelCase.
pub fn camel_case_keys(value: Value) -> Value {
match value {
Value::Object(map) => {
let new_map: serde_json::Map<String, Value> = map
.into_iter()
.map(|(k, v)| (to_camel_case(&k), v))
.collect();
Value::Object(new_map)
}
other => other,
}
}
/// Format a JSON value as a pretty-printed string.
pub fn format_json<T: Serialize>(value: &T) -> String {
serde_json::to_string_pretty(value).unwrap()
}
+181
View File
@@ -0,0 +1,181 @@
//! Shared field generation logic.
//!
//! This module contains the core field generation logic that is shared
//! across all language backends. The `LanguageSyntax` trait is used to
//! abstract over language-specific formatting.
use std::fmt::Write;
use brk_types::MetricLeafWithSchema;
use crate::{ClientMetadata, LanguageSyntax, PatternField, StructuralPattern};
/// Create a path suffix from a name.
/// Adds `_` prefix only if the name doesn't already start with `_`.
fn path_suffix(name: &str) -> String {
if name.starts_with('_') {
name.to_string()
} else {
format!("_{}", name)
}
}
/// Compute path expression from pattern mode and field part.
fn compute_path_expr<S: LanguageSyntax>(
syntax: &S,
pattern: &StructuralPattern,
field: &PatternField,
base_var: &str,
) -> String {
match pattern.get_field_part(&field.name) {
Some(part) => {
if pattern.is_suffix_mode() {
syntax.suffix_expr(base_var, part)
} else {
syntax.prefix_expr(part, base_var)
}
}
None => syntax.path_expr(base_var, &path_suffix(&field.name)),
}
}
/// Compute field value from path expression.
fn compute_field_value<S: LanguageSyntax>(
syntax: &S,
field: &PatternField,
metadata: &ClientMetadata,
path_expr: &str,
) -> String {
if metadata.is_pattern_type(&field.rust_type) {
syntax.constructor(&field.rust_type, path_expr)
} else if let Some(accessor) = metadata.find_index_set_pattern(&field.indexes) {
syntax.constructor(&accessor.name, path_expr)
} else if field.is_branch() {
syntax.constructor(&field.rust_type, path_expr)
} else {
panic!(
"Field '{}' has no matching pattern or index accessor. All metrics must be indexed.",
field.name
)
}
}
/// Generate a parameterized field using the language syntax.
///
/// This is used for pattern instances where fields use an accumulated
/// metric name that's built up through the tree traversal.
pub fn generate_parameterized_field<S: LanguageSyntax>(
output: &mut String,
syntax: &S,
field: &PatternField,
pattern: &StructuralPattern,
metadata: &ClientMetadata,
indent: &str,
) {
let field_name = syntax.field_name(&field.name);
let type_ann = metadata.field_type_annotation(field, pattern.is_generic, None, syntax.generic_syntax());
let path_expr = compute_path_expr(syntax, pattern, field, "acc");
let value = compute_field_value(syntax, field, metadata, &path_expr);
writeln!(output, "{}", syntax.field_init(indent, &field_name, &type_ann, &value)).unwrap();
}
/// Generate a tree node field with a specific child node for pattern instance base detection.
///
/// This is used when generating tree nodes where we need to detect the pattern instance
/// base from descendant leaf names.
pub fn generate_tree_node_field<S: LanguageSyntax>(
output: &mut String,
syntax: &S,
field: &PatternField,
metadata: &ClientMetadata,
indent: &str,
child_name: &str,
pattern_base: Option<&str>,
) {
let field_name = syntax.field_name(&field.name);
let type_ann = metadata.field_type_annotation(field, false, None, syntax.generic_syntax());
let value = if metadata.is_pattern_type(&field.rust_type) {
// Use metric base only for parameterizable patterns
let use_base = metadata
.find_pattern(&field.rust_type)
.is_some_and(|p| p.is_parameterizable())
&& pattern_base.is_some();
let path_arg = if use_base {
syntax.string_literal(pattern_base.unwrap())
} else {
syntax.path_expr("base_path", &path_suffix(child_name))
};
syntax.constructor(&field.rust_type, &path_arg)
} else if let Some(accessor) = metadata.find_index_set_pattern(&field.indexes) {
// Leaf field - use metric name if provided, else tree path
let path_arg = pattern_base
.map(|name| syntax.string_literal(name))
.unwrap_or_else(|| syntax.path_expr("base_path", &path_suffix(child_name)));
syntax.constructor(&accessor.name, &path_arg)
} else if field.is_branch() {
// Non-pattern branch - instantiate the nested struct
let path_expr = syntax.path_expr("base_path", &path_suffix(child_name));
syntax.constructor(&field.rust_type, &path_expr)
} else {
// All metrics must be indexed
panic!(
"Field '{}' is a leaf with no index accessor. All metrics must be indexed.",
field.name
)
};
writeln!(output, "{}", syntax.field_init(indent, &field_name, &type_ann, &value)).unwrap();
}
/// Generate a leaf field using the actual metric name from the TreeNode::Leaf.
///
/// This is the shared implementation for all language backends. It uses
/// `leaf.name()` directly to get the correct metric name, avoiding any
/// path concatenation that could produce incorrect names.
///
/// # Arguments
/// * `output` - The string buffer to write to
/// * `syntax` - The language syntax implementation
/// * `client_expr` - The client expression (e.g., "client.clone()", "this", "client")
/// * `tree_field_name` - The field name from the tree structure
/// * `leaf` - The Leaf node containing the actual metric name and indexes
/// * `metadata` - Client metadata for looking up index patterns
/// * `indent` - Indentation string
pub fn generate_leaf_field<S: LanguageSyntax>(
output: &mut String,
syntax: &S,
client_expr: &str,
tree_field_name: &str,
leaf: &MetricLeafWithSchema,
metadata: &ClientMetadata,
indent: &str,
) {
let field_name = syntax.field_name(tree_field_name);
let accessor = metadata
.find_index_set_pattern(leaf.indexes())
.unwrap_or_else(|| {
panic!(
"Metric '{}' has no matching index pattern. All metrics must be indexed.",
leaf.name()
)
});
let type_ann = metadata.field_type_annotation_from_leaf(leaf, syntax.generic_syntax());
let metric_name = syntax.string_literal(leaf.name());
let value = format!(
"{}({}, {})",
syntax.constructor_name(&accessor.name),
client_expr,
metric_name
);
writeln!(
output,
"{}",
syntax.field_init(indent, &field_name, &type_ann, &value)
)
.unwrap();
}
+13
View File
@@ -0,0 +1,13 @@
//! Shared code generation logic.
//!
//! This module contains generation functions that are parameterized by
//! the `LanguageSyntax` trait, allowing them to work across all supported
//! language backends.
mod constants;
mod fields;
mod tree;
pub use constants::*;
pub use fields::*;
pub use tree::*;
+156
View File
@@ -0,0 +1,156 @@
//! Shared tree generation helpers.
use std::collections::{BTreeMap, BTreeSet};
use brk_types::TreeNode;
use crate::{
ClientMetadata, PatternBaseResult, PatternField, child_type_name, get_fields_with_child_info,
};
/// Build a child path by appending a child name to a parent path.
/// Uses "/" as separator. If parent is empty, returns just the child name.
#[inline]
pub fn build_child_path(parent: &str, child: &str) -> String {
if parent.is_empty() {
child.to_string()
} else {
format!("{}/{}", parent, child)
}
}
/// Pre-computed context for a single child node.
pub struct ChildContext<'a> {
/// The child's field name in the tree.
pub name: &'a str,
/// The child node.
pub node: &'a TreeNode,
/// The field info for this child.
pub field: PatternField,
/// Child fields if this is a branch (for pattern lookup).
pub child_fields: Option<Vec<PatternField>>,
/// Pattern analysis result.
pub base_result: PatternBaseResult,
/// Whether this is a leaf node.
pub is_leaf: bool,
/// Whether to use an inline type instead of a pattern type (only meaningful for branches).
pub should_inline: bool,
/// The type name to use for inline branches.
pub inline_type_name: String,
}
/// Context for generating a tree node, returned by `prepare_tree_node`.
pub struct TreeNodeContext<'a> {
/// Pre-computed context for each child.
pub children: Vec<ChildContext<'a>>,
}
/// Prepare a tree node for generation.
/// Returns None if the node should be skipped (not a branch, already generated,
/// or matches a parameterizable pattern).
///
/// The `path` parameter is the tree path to this node (e.g., "distribution/utxoCohorts").
/// It's used to look up pre-computed PatternBaseResult from the analysis phase.
pub fn prepare_tree_node<'a>(
node: &'a TreeNode,
name: &str,
path: &str,
pattern_lookup: &BTreeMap<Vec<PatternField>, String>,
metadata: &ClientMetadata,
generated: &mut BTreeSet<String>,
) -> Option<TreeNodeContext<'a>> {
let TreeNode::Branch(branch_children) = node else {
return None;
};
let fields_with_child_info = get_fields_with_child_info(branch_children, name, pattern_lookup);
let fields: Vec<PatternField> = fields_with_child_info
.iter()
.map(|(f, _)| f.clone())
.collect();
// Look up the pre-computed base result, or use a default that forces inlining
let base_result = metadata
.get_node_base(path)
.cloned()
.unwrap_or_else(PatternBaseResult::force_inline);
// Skip if this matches a parameterizable pattern AND has no outlier AND field parts match
let pattern_compatible = pattern_lookup
.get(&fields)
.and_then(|name| metadata.find_pattern(name))
.is_none_or(|p| {
p.is_suffix_mode() == base_result.is_suffix_mode
&& p.field_parts_match(&base_result.field_parts)
});
if let Some(pattern_name) = pattern_lookup.get(&fields)
&& pattern_name != name
&& metadata.is_parameterizable(pattern_name)
&& !base_result.has_outlier
&& pattern_compatible
{
return None;
}
// Skip if already generated
if generated.contains(name) {
return None;
}
generated.insert(name.to_string());
// Build child contexts with pre-computed decisions
let children: Vec<ChildContext<'a>> = branch_children
.iter()
.zip(fields_with_child_info)
.map(|((child_name, child_node), (field, child_fields))| {
let is_leaf = matches!(child_node, TreeNode::Leaf(_));
// Build child path and look up its pre-computed base result
let child_path = build_child_path(path, child_name);
let base_result = metadata
.get_node_base(&child_path)
.cloned()
.unwrap_or_else(PatternBaseResult::force_inline);
// For type annotations: use pattern type if ANY pattern matches
let matches_any_pattern = child_fields
.as_ref()
.is_some_and(|cf| metadata.matches_pattern(cf));
// Check if the pattern mode AND field parts match the instance
// Uses is_none_or so that "no pattern" doesn't trigger inlining
let pattern_compatible = child_fields
.as_ref()
.and_then(|cf| metadata.find_pattern_by_fields(cf))
.is_none_or(|p| {
p.is_suffix_mode() == base_result.is_suffix_mode
&& p.field_parts_match(&base_result.field_parts)
});
// should_inline determines if we generate an inline struct type
// We inline if: it's a branch AND (doesn't match any pattern OR pattern incompatible OR has outlier)
let should_inline = !is_leaf
&& (!matches_any_pattern || !pattern_compatible || base_result.has_outlier);
// Inline type name (only used when should_inline is true)
let inline_type_name = if should_inline {
child_type_name(name, child_name)
} else {
String::new()
};
ChildContext {
name: child_name,
node: child_node,
field,
child_fields,
base_result,
is_leaf,
should_inline,
inline_type_name,
}
})
.collect();
Some(TreeNodeContext { children })
}
@@ -0,0 +1,139 @@
//! JavaScript API method generation.
use std::fmt::Write;
use crate::{Endpoint, Parameter, generators::{normalize_return_type, write_description}, to_camel_case};
/// Generate API methods for the BrkClient class.
pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
for endpoint in endpoints {
if !endpoint.should_generate() {
continue;
}
let method_name = endpoint_to_method_name(endpoint);
let base_return_type =
normalize_return_type(endpoint.response_type.as_deref().unwrap_or("*"));
let return_type = if endpoint.supports_csv {
format!("{} | string", base_return_type)
} else {
base_return_type
};
writeln!(output, " /**").unwrap();
if let Some(summary) = &endpoint.summary {
writeln!(output, " * {}", summary).unwrap();
}
if let Some(desc) = &endpoint.description
&& endpoint.summary.as_ref() != Some(desc)
{
writeln!(output, " *").unwrap();
write_description(output, desc, " * ", " *");
}
// Add endpoint path
writeln!(output, " *").unwrap();
writeln!(output, " * Endpoint: `{} {}`", endpoint.method.to_uppercase(), endpoint.path).unwrap();
if !endpoint.path_params.is_empty() || !endpoint.query_params.is_empty() {
writeln!(output, " *").unwrap();
}
for param in &endpoint.path_params {
let desc = format_param_desc(param.description.as_deref());
writeln!(
output,
" * @param {{{}}} {}{}",
param.param_type, param.name, desc
)
.unwrap();
}
for param in &endpoint.query_params {
let optional = if param.required { "" } else { "=" };
let desc = format_param_desc(param.description.as_deref());
writeln!(
output,
" * @param {{{}{}}} [{}]{}",
param.param_type, optional, param.name, desc
)
.unwrap();
}
writeln!(output, " * @returns {{Promise<{}>}}", return_type).unwrap();
writeln!(output, " */").unwrap();
let params = build_method_params(endpoint);
writeln!(output, " async {}({}) {{", method_name, params).unwrap();
let path = build_path_template(&endpoint.path, &endpoint.path_params);
if endpoint.query_params.is_empty() {
writeln!(output, " return this.getJson(`{}`);", path).unwrap();
} else {
writeln!(output, " const params = new URLSearchParams();").unwrap();
for param in &endpoint.query_params {
if param.required {
writeln!(
output,
" params.set('{}', String({}));",
param.name, param.name
)
.unwrap();
} else {
writeln!(
output,
" if ({} !== undefined) params.set('{}', String({}));",
param.name, param.name, param.name
)
.unwrap();
}
}
writeln!(output, " const query = params.toString();").unwrap();
writeln!(output, " const path = `{}${{query ? '?' + query : ''}}`;", path).unwrap();
if endpoint.supports_csv {
writeln!(output, " if (format === 'csv') {{").unwrap();
writeln!(output, " return this.getText(path);").unwrap();
writeln!(output, " }}").unwrap();
writeln!(output, " return this.getJson(path);").unwrap();
} else {
writeln!(output, " return this.getJson(path);").unwrap();
}
}
writeln!(output, " }}\n").unwrap();
}
}
fn endpoint_to_method_name(endpoint: &Endpoint) -> String {
to_camel_case(&endpoint.operation_name())
}
fn build_method_params(endpoint: &Endpoint) -> String {
let mut params = Vec::new();
for param in &endpoint.path_params {
params.push(param.name.clone());
}
for param in &endpoint.query_params {
params.push(param.name.clone());
}
params.join(", ")
}
fn build_path_template(path: &str, path_params: &[Parameter]) -> String {
let mut result = path.to_string();
for param in path_params {
let placeholder = format!("{{{}}}", param.name);
let interpolation = format!("${{{}}}", param.name);
result = result.replace(&placeholder, &interpolation);
}
result
}
/// Format param description with dash prefix, or empty string if no description.
fn format_param_desc(desc: Option<&str>) -> String {
match desc {
Some(d) if !d.is_empty() => format!(" - {}", d),
_ => String::new(),
}
}
@@ -0,0 +1,626 @@
//! JavaScript base client and pattern factory generation.
use std::fmt::Write;
use crate::{
ClientConstants, ClientMetadata, CohortConstants, GenericSyntax, IndexSetPattern,
JavaScriptSyntax, StructuralPattern, camel_case_keys, format_json,
generate_parameterized_field, to_camel_case,
};
/// Generate the base BrkClient class with HTTP functionality.
pub fn generate_base_client(output: &mut String) {
writeln!(
output,
r#"/**
* @typedef {{Object}} BrkClientOptions
* @property {{string}} baseUrl - Base URL for the API
* @property {{number}} [timeout] - Request timeout in milliseconds
* @property {{string|boolean}} [cache] - Enable browser cache with default name (true), custom name (string), or disable (false). No effect in Node.js. Default: true
*/
const _isBrowser = typeof window !== 'undefined' && 'caches' in window;
const _runIdle = (/** @type {{VoidFunction}} */ fn) => (globalThis.requestIdleCallback ?? setTimeout)(fn);
const _defaultCacheName = '__BRK_CLIENT__';
/**
* @param {{string|boolean|undefined}} cache
* @returns {{Promise<Cache | null>}}
*/
const _openCache = (cache) => {{
if (!_isBrowser || cache === false) return Promise.resolve(null);
const name = typeof cache === 'string' ? cache : _defaultCacheName;
return caches.open(name).catch(() => null);
}};
/**
* Custom error class for BRK client errors
*/
class BrkError extends Error {{
/**
* @param {{string}} message
* @param {{number}} [status]
*/
constructor(message, status) {{
super(message);
this.name = 'BrkError';
this.status = status;
}}
}}
// Date conversion constants and helpers
const _GENESIS = new Date(2009, 0, 3); // dateindex 0, weekindex 0
const _DAY_ONE = new Date(2009, 0, 9); // dateindex 1 (6 day gap after genesis)
const _MS_PER_DAY = 24 * 60 * 60 * 1000;
const _MS_PER_WEEK = 7 * _MS_PER_DAY;
const _DATE_INDEXES = new Set(['dateindex', 'weekindex', 'monthindex', 'yearindex', 'quarterindex', 'semesterindex', 'decadeindex']);
/** @param {{number}} months @returns {{globalThis.Date}} */
const _addMonths = (months) => new Date(2009, months, 1);
/**
* Convert an index value to a Date for date-based indexes.
* @param {{Index}} index - The index type
* @param {{number}} i - The index value
* @returns {{globalThis.Date}}
*/
function indexToDate(index, i) {{
switch (index) {{
case 'dateindex': return i === 0 ? _GENESIS : new Date(_DAY_ONE.getTime() + (i - 1) * _MS_PER_DAY);
case 'weekindex': return new Date(_GENESIS.getTime() + i * _MS_PER_WEEK);
case 'monthindex': return _addMonths(i);
case 'yearindex': return new Date(2009 + i, 0, 1);
case 'quarterindex': return _addMonths(i * 3);
case 'semesterindex': return _addMonths(i * 6);
case 'decadeindex': return new Date(2009 + i * 10, 0, 1);
default: throw new Error(`${{index}} is not a date-based index`);
}}
}}
/**
* Check if an index type is date-based.
* @param {{Index}} index
* @returns {{boolean}}
*/
function isDateIndex(index) {{
return _DATE_INDEXES.has(index);
}}
/**
* Wrap raw metric data with helper methods.
* @template T
* @param {{MetricData<T>}} raw - Raw JSON response
* @returns {{MetricData<T>}}
*/
function _wrapMetricData(raw) {{
const {{ index, start, end, data }} = raw;
return /** @type {{MetricData<T>}} */ ({{
...raw,
dates() {{
/** @type {{globalThis.Date[]}} */
const result = [];
for (let i = start; i < end; i++) result.push(indexToDate(index, i));
return result;
}},
indexes() {{
/** @type {{number[]}} */
const result = [];
for (let i = start; i < end; i++) result.push(i);
return result;
}},
toDateMap() {{
/** @type {{Map<globalThis.Date, T>}} */
const map = new Map();
for (let i = 0; i < data.length; i++) map.set(indexToDate(index, start + i), data[i]);
return map;
}},
toIndexMap() {{
/** @type {{Map<number, T>}} */
const map = new Map();
for (let i = 0; i < data.length; i++) map.set(start + i, data[i]);
return map;
}},
dateEntries() {{
/** @type {{Array<[globalThis.Date, T]>}} */
const result = [];
for (let i = 0; i < data.length; i++) result.push([indexToDate(index, start + i), data[i]]);
return result;
}},
indexEntries() {{
/** @type {{Array<[number, T]>}} */
const result = [];
for (let i = 0; i < data.length; i++) result.push([start + i, data[i]]);
return result;
}},
*iter() {{
for (let i = 0; i < data.length; i++) yield [start + i, data[i]];
}},
*iterDates() {{
for (let i = 0; i < data.length; i++) yield [indexToDate(index, start + i), data[i]];
}},
[Symbol.iterator]() {{
return this.iter();
}},
}});
}}
/**
* @template T
* @typedef {{Object}} MetricData
* @property {{number}} version - Version of the metric data
* @property {{Index}} index - The index type used for this query
* @property {{number}} total - Total number of data points
* @property {{number}} start - Start index (inclusive)
* @property {{number}} end - End index (exclusive)
* @property {{string}} stamp - ISO 8601 timestamp of when the response was generated
* @property {{T[]}} data - The metric data
* @property {{() => globalThis.Date[]}} dates - Convert index range to dates (date-based indexes only)
* @property {{() => number[]}} indexes - Get index range as array
* @property {{() => Map<globalThis.Date, T>}} toDateMap - Return data as Map keyed by date (date-based only)
* @property {{() => Map<number, T>}} toIndexMap - Return data as Map keyed by index
* @property {{() => Array<[globalThis.Date, T]>}} dateEntries - Return data as [date, value] pairs (date-based only)
* @property {{() => Array<[number, T]>}} indexEntries - Return data as [index, value] pairs
* @property {{() => IterableIterator<[number, T]>}} iter - Iterate over [index, value] pairs
* @property {{() => IterableIterator<[globalThis.Date, T]>}} iterDates - Iterate over [date, value] pairs (date-based only)
*/
/** @typedef {{MetricData<any>}} AnyMetricData */
/**
* Thenable interface for await support.
* @template T
* @typedef {{(onfulfilled?: (value: MetricData<T>) => MetricData<T>, onrejected?: (reason: Error) => never) => Promise<MetricData<T>>}} Thenable
*/
/**
* Metric endpoint builder. Callable (returns itself) so both .by.dateindex and .by.dateindex() work.
* @template T
* @typedef {{Object}} MetricEndpointBuilder
* @property {{(index: number) => SingleItemBuilder<T>}} get - Get single item at index
* @property {{(start?: number, end?: number) => RangeBuilder<T>}} slice - Slice like Array.slice
* @property {{(n: number) => RangeBuilder<T>}} first - Get first n items
* @property {{(n: number) => RangeBuilder<T>}} last - Get last n items
* @property {{(n: number) => SkippedBuilder<T>}} skip - Skip first n items, chain with take()
* @property {{(onUpdate?: (value: MetricData<T>) => void) => Promise<MetricData<T>>}} fetch - Fetch all data
* @property {{() => Promise<string>}} fetchCsv - Fetch all data as CSV
* @property {{Thenable<T>}} then - Thenable (await endpoint)
* @property {{string}} path - The endpoint path
*/
/** @typedef {{MetricEndpointBuilder<any>}} AnyMetricEndpointBuilder */
/**
* @template T
* @typedef {{Object}} SingleItemBuilder
* @property {{(onUpdate?: (value: MetricData<T>) => void) => Promise<MetricData<T>>}} fetch - Fetch the item
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
* @property {{Thenable<T>}} then - Thenable
*/
/**
* @template T
* @typedef {{Object}} SkippedBuilder
* @property {{(n: number) => RangeBuilder<T>}} take - Take n items after skipped position
* @property {{(onUpdate?: (value: MetricData<T>) => void) => Promise<MetricData<T>>}} fetch - Fetch from skipped position to end
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
* @property {{Thenable<T>}} then - Thenable
*/
/**
* @template T
* @typedef {{Object}} RangeBuilder
* @property {{(onUpdate?: (value: MetricData<T>) => void) => Promise<MetricData<T>>}} fetch - Fetch the range
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
* @property {{Thenable<T>}} then - Thenable
*/
/**
* @template T
* @typedef {{Object}} MetricPattern
* @property {{string}} name - The metric name
* @property {{Readonly<Partial<Record<Index, MetricEndpointBuilder<T>>>>}} by - Index endpoints as lazy getters. Access via .by.dateindex or .by['dateindex']
* @property {{() => readonly Index[]}} indexes - Get the list of available indexes
* @property {{(index: Index) => MetricEndpointBuilder<T>|undefined}} get - Get an endpoint for a specific index
*/
/** @typedef {{MetricPattern<any>}} AnyMetricPattern */
/**
* Create a metric endpoint builder with typestate pattern.
* @template T
* @param {{BrkClientBase}} client
* @param {{string}} name - The metric vec name
* @param {{Index}} index - The index name
* @returns {{MetricEndpointBuilder<T>}}
*/
function _endpoint(client, name, index) {{
const p = `/api/metric/${{name}}/${{index}}`;
/**
* @param {{number}} [start]
* @param {{number}} [end]
* @param {{string}} [format]
* @returns {{string}}
*/
const buildPath = (start, end, format) => {{
const params = new URLSearchParams();
if (start !== undefined) params.set('start', String(start));
if (end !== undefined) params.set('end', String(end));
if (format) params.set('format', format);
const query = params.toString();
return query ? `${{p}}?${{query}}` : p;
}};
/**
* @param {{number}} [start]
* @param {{number}} [end]
* @returns {{RangeBuilder<T>}}
*/
const rangeBuilder = (start, end) => ({{
fetch(onUpdate) {{ return client._fetchMetricData(buildPath(start, end), onUpdate); }},
fetchCsv() {{ return client.getText(buildPath(start, end, 'csv')); }},
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
}});
/**
* @param {{number}} idx
* @returns {{SingleItemBuilder<T>}}
*/
const singleItemBuilder = (idx) => ({{
fetch(onUpdate) {{ return client._fetchMetricData(buildPath(idx, idx + 1), onUpdate); }},
fetchCsv() {{ return client.getText(buildPath(idx, idx + 1, 'csv')); }},
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
}});
/**
* @param {{number}} start
* @returns {{SkippedBuilder<T>}}
*/
const skippedBuilder = (start) => ({{
take(n) {{ return rangeBuilder(start, start + n); }},
fetch(onUpdate) {{ return client._fetchMetricData(buildPath(start, undefined), onUpdate); }},
fetchCsv() {{ return client.getText(buildPath(start, undefined, 'csv')); }},
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
}});
/** @type {{MetricEndpointBuilder<T>}} */
const endpoint = {{
get(idx) {{ return singleItemBuilder(idx); }},
slice(start, end) {{ return rangeBuilder(start, end); }},
first(n) {{ return rangeBuilder(undefined, n); }},
last(n) {{ return n === 0 ? rangeBuilder(undefined, 0) : rangeBuilder(-n, undefined); }},
skip(n) {{ return skippedBuilder(n); }},
fetch(onUpdate) {{ return client._fetchMetricData(buildPath(), onUpdate); }},
fetchCsv() {{ return client.getText(buildPath(undefined, undefined, 'csv')); }},
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
get path() {{ return p; }},
}};
return endpoint;
}}
/**
* Base HTTP client for making requests with caching support
*/
class BrkClientBase {{
/**
* @param {{BrkClientOptions|string}} options
*/
constructor(options) {{
const isString = typeof options === 'string';
this.baseUrl = isString ? options : options.baseUrl;
this.timeout = isString ? 5000 : (options.timeout ?? 5000);
/** @type {{Promise<Cache | null>}} */
this._cachePromise = _openCache(isString ? undefined : options.cache);
}}
/**
* @param {{string}} path
* @returns {{Promise<Response>}}
*/
async get(path) {{
const base = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
const url = `${{base}}${{path}}`;
const res = await fetch(url, {{ signal: AbortSignal.timeout(this.timeout) }});
if (!res.ok) throw new BrkError(`HTTP ${{res.status}}: ${{url}}`, res.status);
return res;
}}
/**
* Make a GET request with stale-while-revalidate caching
* @template T
* @param {{string}} path
* @param {{(value: T) => void}} [onUpdate] - Called when data is available
* @returns {{Promise<T>}}
*/
async getJson(path, onUpdate) {{
const base = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
const url = `${{base}}${{path}}`;
const cache = await this._cachePromise;
const cachedRes = await cache?.match(url);
const cachedJson = cachedRes ? await cachedRes.json() : null;
if (cachedJson) onUpdate?.(cachedJson);
if (globalThis.navigator?.onLine === false) {{
if (cachedJson) return cachedJson;
throw new BrkError('Offline and no cached data available');
}}
try {{
const res = await this.get(path);
if (cachedRes?.headers.get('ETag') === res.headers.get('ETag')) return cachedJson;
const cloned = res.clone();
const json = await res.json();
onUpdate?.(json);
if (cache) _runIdle(() => cache.put(url, cloned));
return json;
}} catch (e) {{
if (cachedJson) return cachedJson;
throw e;
}}
}}
/**
* Make a GET request and return raw text (for CSV responses)
* @param {{string}} path
* @returns {{Promise<string>}}
*/
async getText(path) {{
const res = await this.get(path);
return res.text();
}}
/**
* Fetch metric data and wrap with helper methods (internal)
* @template T
* @param {{string}} path
* @param {{(value: MetricData<T>) => void}} [onUpdate]
* @returns {{Promise<MetricData<T>>}}
*/
async _fetchMetricData(path, onUpdate) {{
const wrappedOnUpdate = onUpdate ? (/** @type {{MetricData<T>}} */ raw) => onUpdate(_wrapMetricData(raw)) : undefined;
const raw = await this.getJson(path, wrappedOnUpdate);
return _wrapMetricData(raw);
}}
}}
/**
* Build metric name with suffix.
* @param {{string}} acc - Accumulated prefix
* @param {{string}} s - Metric suffix
* @returns {{string}}
*/
const _m = (acc, s) => s ? (acc ? `${{acc}}_${{s}}` : s) : acc;
/**
* Build metric name with prefix.
* @param {{string}} prefix - Prefix to prepend
* @param {{string}} acc - Accumulated name
* @returns {{string}}
*/
const _p = (prefix, acc) => acc ? `${{prefix}}_${{acc}}` : prefix;
"#
)
.unwrap();
}
/// Generate static constants for the BrkClient class.
pub fn generate_static_constants(output: &mut String) {
let constants = ClientConstants::collect();
// VERSION, INDEXES, POOL_ID_TO_POOL_NAME
writeln!(output, " VERSION = \"{}\";\n", constants.version).unwrap();
write_static_const(output, "INDEXES", &format_json(&constants.indexes));
write_static_const(output, "POOL_ID_TO_POOL_NAME", &format_json(&constants.pool_map));
// Cohort constants with camelCase keys
for (name, value) in CohortConstants::all() {
write_static_const(output, name, &format_json(&camel_case_keys(value)));
}
// Helper methods
writeln!(
output,
r#" /**
* Convert an index value to a Date for date-based indexes.
* @param {{Index}} index - The index type
* @param {{number}} i - The index value
* @returns {{globalThis.Date}}
*/
indexToDate(index, i) {{
return indexToDate(index, i);
}}
/**
* Check if an index type is date-based.
* @param {{Index}} index
* @returns {{boolean}}
*/
isDateIndex(index) {{
return isDateIndex(index);
}}
"#
)
.unwrap();
}
fn indent_json_const(json: &str) -> String {
json.lines()
.enumerate()
.map(|(i, line)| {
if i == 0 {
line.to_string()
} else {
format!(" {}", line)
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn write_static_const(output: &mut String, name: &str, json: &str) {
writeln!(
output,
" {} = /** @type {{const}} */ ({});\n",
name,
indent_json_const(json)
)
.unwrap();
}
/// Generate index accessor factory functions.
pub fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) {
if patterns.is_empty() {
return;
}
writeln!(output, "// Index group constants and factory\n").unwrap();
// Generate index array constants (e.g., _i1 = ["dateindex", "height"])
for (i, pattern) in patterns.iter().enumerate() {
write!(output, "const _i{} = /** @type {{const}} */ ([", i + 1).unwrap();
for (j, index) in pattern.indexes.iter().enumerate() {
if j > 0 {
write!(output, ", ").unwrap();
}
write!(output, "\"{}\"", index.serialize_long()).unwrap();
}
writeln!(output, "]);").unwrap();
}
writeln!(output).unwrap();
// Generate ONE generic metric pattern factory
writeln!(
output,
r#"/**
* Generic metric pattern factory.
* @template T
* @param {{BrkClientBase}} client
* @param {{string}} name - The metric vec name
* @param {{readonly Index[]}} indexes - The supported indexes
*/
function _mp(client, name, indexes) {{
const by = /** @type {{any}} */ ({{}});
for (const idx of indexes) {{
Object.defineProperty(by, idx, {{
get() {{ return _endpoint(client, name, idx); }},
enumerable: true,
configurable: true
}});
}}
return {{
name,
by,
indexes() {{ return indexes; }},
/** @param {{Index}} index */
get(index) {{ return indexes.includes(index) ? _endpoint(client, name, index) : undefined; }}
}};
}}
"#
)
.unwrap();
// Generate typedefs and thin wrapper functions
for (i, pattern) in patterns.iter().enumerate() {
// Generate typedef for type safety
let by_fields: Vec<String> = pattern
.indexes
.iter()
.map(|idx| {
format!(
"readonly {}: MetricEndpointBuilder<T>",
idx.serialize_long()
)
})
.collect();
let by_type = format!("{{ {} }}", by_fields.join(", "));
writeln!(
output,
"/** @template T @typedef {{{{ name: string, by: {}, indexes: () => readonly Index[], get: (index: Index) => MetricEndpointBuilder<T>|undefined }}}} {} */",
by_type, pattern.name
)
.unwrap();
// Generate thin wrapper that calls the generic factory
writeln!(
output,
"/** @template T @param {{BrkClientBase}} client @param {{string}} name @returns {{{}<T>}} */",
pattern.name
)
.unwrap();
writeln!(
output,
"function create{}(client, name) {{ return _mp(client, name, _i{}); }}",
pattern.name,
i + 1
)
.unwrap();
}
writeln!(output).unwrap();
}
/// Generate structural pattern factory functions.
pub fn generate_structural_patterns(
output: &mut String,
patterns: &[StructuralPattern],
metadata: &ClientMetadata,
) {
if patterns.is_empty() {
return;
}
writeln!(output, "// Reusable structural pattern factories\n").unwrap();
for pattern in patterns {
// Generate typedef
writeln!(output, "/**").unwrap();
if pattern.is_generic {
writeln!(output, " * @template T").unwrap();
}
writeln!(output, " * @typedef {{Object}} {}", pattern.name).unwrap();
for field in &pattern.fields {
let js_type = metadata.field_type_annotation(
field,
pattern.is_generic,
None,
GenericSyntax::JAVASCRIPT,
);
writeln!(
output,
" * @property {{{}}} {}",
js_type,
to_camel_case(&field.name)
)
.unwrap();
}
writeln!(output, " */\n").unwrap();
// Generate factory function for ALL patterns
writeln!(output, "/**").unwrap();
writeln!(output, " * Create a {} pattern node", pattern.name).unwrap();
if pattern.is_generic {
writeln!(output, " * @template T").unwrap();
}
writeln!(output, " * @param {{BrkClientBase}} client").unwrap();
writeln!(output, " * @param {{string}} acc - Accumulated metric name").unwrap();
let return_type = if pattern.is_generic {
format!("{}<T>", pattern.name)
} else {
pattern.name.clone()
};
writeln!(output, " * @returns {{{}}}", return_type).unwrap();
writeln!(output, " */").unwrap();
writeln!(output, "function create{}(client, acc) {{", pattern.name).unwrap();
writeln!(output, " return {{").unwrap();
let syntax = JavaScriptSyntax;
for field in &pattern.fields {
generate_parameterized_field(output, &syntax, field, pattern, metadata, " ");
}
writeln!(output, " }};").unwrap();
writeln!(output, "}}\n").unwrap();
}
}
@@ -0,0 +1,66 @@
//! JavaScript client generation.
//!
//! This module generates a JavaScript + JSDoc client for the BRK API.
mod api;
pub mod client;
pub mod tree;
pub mod types;
use std::{fmt::Write, fs, io, path::Path};
use serde_json::json;
use super::write_if_changed;
use crate::{ClientMetadata, Endpoint, TypeSchemas, VERSION};
/// Generate JavaScript + JSDoc client from metadata and OpenAPI endpoints.
///
/// `output_path` is the full path to the output file (e.g., "modules/brk-client/index.js").
pub fn generate_javascript_client(
metadata: &ClientMetadata,
endpoints: &[Endpoint],
schemas: &TypeSchemas,
output_path: &Path,
) -> io::Result<()> {
let mut output = String::new();
writeln!(output, "// Auto-generated BRK JavaScript client").unwrap();
writeln!(output, "// Do not edit manually\n").unwrap();
types::generate_type_definitions(&mut output, schemas);
client::generate_base_client(&mut output);
client::generate_index_accessors(&mut output, &metadata.index_set_patterns);
client::generate_structural_patterns(&mut output, &metadata.structural_patterns, metadata);
tree::generate_tree_typedefs(&mut output, &metadata.catalog, metadata);
tree::generate_main_client(&mut output, &metadata.catalog, metadata, endpoints);
write_if_changed(output_path, &output)?;
// Update package.json version if it exists in the same directory
if let Some(parent) = output_path.parent() {
let package_json_path = parent.join("package.json");
if package_json_path.exists() {
update_package_json_version(&package_json_path)?;
}
}
Ok(())
}
fn update_package_json_version(package_json_path: &Path) -> io::Result<()> {
let content = fs::read_to_string(package_json_path)?;
let mut package: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
if let Some(obj) = package.as_object_mut() {
obj.insert("version".to_string(), json!(VERSION));
}
let updated = serde_json::to_string_pretty(&package)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
write_if_changed(package_json_path, &(updated + "\n"))?;
Ok(())
}
@@ -0,0 +1,232 @@
//! JavaScript tree structure generation.
use std::collections::BTreeSet;
use std::fmt::Write;
use brk_types::TreeNode;
use crate::{
ClientMetadata, Endpoint, GenericSyntax, JavaScriptSyntax, PatternField, build_child_path,
generate_leaf_field, prepare_tree_node, to_camel_case,
};
use super::api::generate_api_methods;
use super::client::generate_static_constants;
/// Generate JSDoc typedefs for the metrics tree.
pub fn generate_tree_typedefs(output: &mut String, catalog: &TreeNode, metadata: &ClientMetadata) {
writeln!(output, "// Catalog tree typedefs\n").unwrap();
let pattern_lookup = metadata.pattern_lookup();
let mut generated = BTreeSet::new();
generate_tree_typedef(
output,
"MetricsTree",
"",
catalog,
&pattern_lookup,
metadata,
&mut generated,
);
}
fn generate_tree_typedef(
output: &mut String,
name: &str,
path: &str,
node: &TreeNode,
pattern_lookup: &std::collections::BTreeMap<Vec<PatternField>, String>,
metadata: &ClientMetadata,
generated: &mut BTreeSet<String>,
) {
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
return;
};
writeln!(output, "/**").unwrap();
writeln!(output, " * @typedef {{Object}} {}", name).unwrap();
for child in &ctx.children {
let js_type = if child.should_inline {
child.inline_type_name.clone()
} else {
metadata.resolve_tree_field_type(
&child.field,
child.child_fields.as_deref(),
name,
child.name,
GenericSyntax::JAVASCRIPT,
)
};
writeln!(
output,
" * @property {{{}}} {}",
js_type,
to_camel_case(&child.field.name)
)
.unwrap();
}
writeln!(output, " */\n").unwrap();
// Generate child typedefs
for child in &ctx.children {
if child.should_inline {
let child_path = build_child_path(path, child.name);
generate_tree_typedef(
output,
&child.inline_type_name,
&child_path,
child.node,
pattern_lookup,
metadata,
generated,
);
}
}
}
/// Generate the main BrkClient class.
pub fn generate_main_client(
output: &mut String,
catalog: &TreeNode,
metadata: &ClientMetadata,
endpoints: &[Endpoint],
) {
let pattern_lookup = metadata.pattern_lookup();
writeln!(output, "/**").unwrap();
writeln!(
output,
" * Main BRK client with metrics tree and API methods"
)
.unwrap();
writeln!(output, " * @extends BrkClientBase").unwrap();
writeln!(output, " */").unwrap();
writeln!(output, "class BrkClient extends BrkClientBase {{").unwrap();
generate_static_constants(output);
writeln!(output, " /**").unwrap();
writeln!(output, " * @param {{BrkClientOptions|string}} options").unwrap();
writeln!(output, " */").unwrap();
writeln!(output, " constructor(options) {{").unwrap();
writeln!(output, " super(options);").unwrap();
writeln!(output, " /** @type {{MetricsTree}} */").unwrap();
writeln!(output, " this.metrics = this._buildTree('');").unwrap();
writeln!(output, " }}\n").unwrap();
writeln!(output, " /**").unwrap();
writeln!(output, " * @private").unwrap();
writeln!(output, " * @param {{string}} basePath").unwrap();
writeln!(output, " * @returns {{MetricsTree}}").unwrap();
writeln!(output, " */").unwrap();
writeln!(output, " _buildTree(basePath) {{").unwrap();
writeln!(output, " return {{").unwrap();
let mut generated = BTreeSet::new();
generate_tree_initializer(
output,
catalog,
"MetricsTree",
"",
3,
&pattern_lookup,
metadata,
&mut generated,
);
writeln!(output, " }};").unwrap();
writeln!(output, " }}\n").unwrap();
writeln!(output, " /**").unwrap();
writeln!(
output,
" * Create a dynamic metric endpoint builder for any metric/index combination."
)
.unwrap();
writeln!(output, " *").unwrap();
writeln!(
output,
" * Use this for programmatic access when the metric name is determined at runtime."
)
.unwrap();
writeln!(
output,
" * For type-safe access, use the `metrics` tree instead."
)
.unwrap();
writeln!(output, " *").unwrap();
writeln!(output, " * @param {{string}} metric - The metric name").unwrap();
writeln!(output, " * @param {{Index}} index - The index name").unwrap();
writeln!(output, " * @returns {{MetricEndpointBuilder<unknown>}}").unwrap();
writeln!(output, " */").unwrap();
writeln!(output, " metric(metric, index) {{").unwrap();
writeln!(output, " return _endpoint(this, metric, index);").unwrap();
writeln!(output, " }}\n").unwrap();
generate_api_methods(output, endpoints);
writeln!(output, "}}\n").unwrap();
writeln!(output, "export {{ BrkClient, BrkError }};").unwrap();
}
#[allow(clippy::too_many_arguments)]
fn generate_tree_initializer(
output: &mut String,
node: &TreeNode,
name: &str,
path: &str,
indent: usize,
pattern_lookup: &std::collections::BTreeMap<Vec<PatternField>, String>,
metadata: &ClientMetadata,
generated: &mut BTreeSet<String>,
) {
let indent_str = " ".repeat(indent);
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
return;
};
let syntax = JavaScriptSyntax;
for child in &ctx.children {
let field_name = to_camel_case(child.name);
if child.is_leaf {
if let TreeNode::Leaf(leaf) = child.node {
generate_leaf_field(
output,
&syntax,
"this",
child.name,
leaf,
metadata,
&indent_str,
);
}
} else if child.should_inline {
// Inline object
let child_path = build_child_path(path, child.name);
writeln!(output, "{}{}: {{", indent_str, field_name).unwrap();
generate_tree_initializer(
output,
child.node,
&child.inline_type_name,
&child_path,
indent + 1,
pattern_lookup,
metadata,
generated,
);
writeln!(output, "{}}},", indent_str).unwrap();
} else {
// Use pattern factory
writeln!(
output,
"{}{}: create{}(this, '{}'),",
indent_str, field_name, child.field.rust_type, child.base_result.base
)
.unwrap();
}
}
}
@@ -0,0 +1,197 @@
//! JavaScript type definitions generation.
use std::fmt::Write;
use serde_json::Value;
use crate::{TypeSchemas, generators::{MANUAL_GENERIC_TYPES, write_description}, get_union_variants, ref_to_type_name, to_camel_case};
/// Generate JSDoc type definitions from OpenAPI schemas.
pub fn generate_type_definitions(output: &mut String, schemas: &TypeSchemas) {
if schemas.is_empty() {
return;
}
writeln!(output, "// Type definitions\n").unwrap();
for (name, schema) in schemas {
if MANUAL_GENERIC_TYPES.contains(&name.as_str()) {
continue;
}
let js_type = schema_to_js_type(schema, Some(name));
let type_desc = schema.get("description").and_then(|d| d.as_str());
if is_primitive_alias(schema) {
if let Some(desc) = type_desc {
writeln!(output, "/**").unwrap();
write_description(output, desc, " * ", " *");
writeln!(output, " *").unwrap();
writeln!(output, " * @typedef {{{}}} {}", js_type, name).unwrap();
writeln!(output, " */").unwrap();
} else {
writeln!(output, "/** @typedef {{{}}} {} */", js_type, name).unwrap();
}
} else if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
writeln!(output, "/**").unwrap();
if let Some(desc) = type_desc {
write_description(output, desc, " * ", " *");
writeln!(output, " *").unwrap();
}
writeln!(output, " * @typedef {{Object}} {}", name).unwrap();
for (prop_name, prop_schema) in props {
let prop_type = schema_to_js_type(prop_schema, Some(name));
let required = schema
.get("required")
.and_then(|r| r.as_array())
.map(|arr| arr.iter().any(|v| v.as_str() == Some(prop_name)))
.unwrap_or(false);
let optional = if required { "" } else { "=" };
let safe_name = to_camel_case(prop_name);
let prop_desc = prop_schema
.get("description")
.and_then(|d| d.as_str())
.map(|d| format!(" - {}", d))
.unwrap_or_default();
writeln!(
output,
" * @property {{{}{}}} {}{}",
prop_type, optional, safe_name, prop_desc
)
.unwrap();
}
writeln!(output, " */").unwrap();
} else if let Some(desc) = type_desc {
writeln!(output, "/**").unwrap();
write_description(output, desc, " * ", " *");
writeln!(output, " *").unwrap();
writeln!(output, " * @typedef {{{}}} {}", js_type, name).unwrap();
writeln!(output, " */").unwrap();
} else {
writeln!(output, "/** @typedef {{{}}} {} */", js_type, name).unwrap();
}
}
writeln!(output).unwrap();
}
fn is_primitive_alias(schema: &Value) -> bool {
schema.get("properties").is_none()
&& schema.get("items").is_none()
&& schema.get("anyOf").is_none()
&& schema.get("oneOf").is_none()
&& schema.get("enum").is_none()
}
fn json_type_to_js(ty: &str, schema: &Value, current_type: Option<&str>) -> String {
match ty {
"integer" | "number" => "number".to_string(),
"boolean" => "boolean".to_string(),
"string" => "string".to_string(),
"null" => "null".to_string(),
"array" => {
let item_type = schema
.get("items")
.map(|s| schema_to_js_type(s, current_type))
.unwrap_or_else(|| "*".to_string());
format!("{}[]", item_type)
}
"object" => {
if let Some(add_props) = schema.get("additionalProperties") {
let value_type = schema_to_js_type(add_props, current_type);
return format!("{{ [key: string]: {} }}", value_type);
}
"Object".to_string()
}
_ => "*".to_string(),
}
}
/// Convert a JSON schema to a JavaScript type string.
pub fn schema_to_js_type(schema: &Value, current_type: Option<&str>) -> String {
if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array()) {
for item in all_of {
let resolved = schema_to_js_type(item, current_type);
if resolved != "*" {
return resolved;
}
}
}
if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
return ref_to_type_name(ref_path).unwrap_or("*").to_string();
}
if let Some(enum_values) = schema.get("enum").and_then(|e| e.as_array()) {
let literals: Vec<String> = enum_values
.iter()
.filter_map(|v| v.as_str())
.map(|s| format!("\"{}\"", s))
.collect();
if !literals.is_empty() {
return format!("({})", literals.join("|"));
}
}
if let Some(ty) = schema.get("type") {
if let Some(type_array) = ty.as_array() {
let types: Vec<String> = type_array
.iter()
.filter_map(|t| t.as_str())
.filter(|t| *t != "null")
.map(|t| json_type_to_js(t, schema, current_type))
.collect();
let has_null = type_array.iter().any(|t| t.as_str() == Some("null"));
if types.len() == 1 {
let base_type = &types[0];
return if has_null {
format!("?{}", base_type)
} else {
base_type.clone()
};
} else if !types.is_empty() {
let union = format!("({})", types.join("|"));
return if has_null {
format!("?{}", union)
} else {
union
};
}
}
if let Some(ty_str) = ty.as_str() {
return json_type_to_js(ty_str, schema, current_type);
}
}
if let Some(variants) = get_union_variants(schema) {
let types: Vec<String> = variants
.iter()
.map(|v| schema_to_js_type(v, current_type))
.collect();
let filtered: Vec<_> = types.iter().filter(|t| *t != "*").collect();
if !filtered.is_empty() {
return format!(
"({})",
filtered
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join("|")
);
}
return format!("({})", types.join("|"));
}
if let Some(format) = schema.get("format").and_then(|f| f.as_str()) {
return match format {
"int32" | "int64" => "number".to_string(),
"float" | "double" => "number".to_string(),
"date" | "date-time" => "string".to_string(),
_ => "*".to_string(),
};
}
"*".to_string()
}
+54
View File
@@ -0,0 +1,54 @@
//! Code generators for client libraries.
//!
//! Each language has its own submodule with focused files:
//! - `types.rs` - Type definitions
//! - `client.rs` - Base client and pattern factories
//! - `tree.rs` - Tree structure generation
//! - `api.rs` - API method generation
//! - `mod.rs` - Entry point
use std::{fmt::Write, fs, io, path::Path};
pub mod javascript;
pub mod python;
pub mod rust;
pub use javascript::generate_javascript_client;
pub use python::generate_python_client;
pub use rust::generate_rust_client;
/// Types that are manually defined as generics in client code, not from schema.
pub const MANUAL_GENERIC_TYPES: &[&str] = &["MetricData", "MetricEndpoint"];
/// Write a multi-line description with the given prefix for each line.
/// `empty_prefix` is used for blank lines (e.g., " *" without trailing space).
pub fn write_description(output: &mut String, desc: &str, prefix: &str, empty_prefix: &str) {
for line in desc.lines() {
if line.is_empty() {
writeln!(output, "{}", empty_prefix).unwrap();
} else {
writeln!(output, "{}{}", prefix, line).unwrap();
}
}
}
/// Replace generic types with their Any variants in return types.
/// Used by JS and Python generators.
pub fn normalize_return_type(return_type: &str) -> String {
let mut result = return_type.to_string();
for type_name in MANUAL_GENERIC_TYPES {
result = result.replace(type_name, &format!("Any{}", type_name));
}
result
}
/// Write content to a file only if it differs from existing content.
/// Preserves mtime when unchanged, avoiding unnecessary cargo rebuilds.
pub fn write_if_changed(path: &Path, content: &str) -> io::Result<()> {
if let Ok(existing) = fs::read_to_string(path)
&& existing == content
{
return Ok(());
}
fs::write(path, content)
}
@@ -0,0 +1,209 @@
//! Python API method generation.
use std::fmt::Write;
use crate::{Endpoint, Parameter, escape_python_keyword, generators::{normalize_return_type, write_description}, to_snake_case};
use super::client::generate_class_constants;
use super::types::js_type_to_python;
/// Generate the main client class
pub fn generate_main_client(output: &mut String, endpoints: &[Endpoint]) {
writeln!(output, "class BrkClient(BrkClientBase):").unwrap();
writeln!(
output,
" \"\"\"Main BRK client with metrics tree and API methods.\"\"\""
)
.unwrap();
writeln!(output).unwrap();
// Generate class-level constants
generate_class_constants(output);
writeln!(
output,
" def __init__(self, base_url: str = 'http://localhost:3000', timeout: float = 30.0):"
)
.unwrap();
writeln!(output, " super().__init__(base_url, timeout)").unwrap();
writeln!(output, " self.metrics = MetricsTree(self)").unwrap();
writeln!(output).unwrap();
// Generate metric() method for dynamic metric access
writeln!(output, " def metric(self, metric: str, index: Index) -> MetricEndpointBuilder[Any]:").unwrap();
writeln!(output, " \"\"\"Create a dynamic metric endpoint builder for any metric/index combination.").unwrap();
writeln!(output).unwrap();
writeln!(output, " Use this for programmatic access when the metric name is determined at runtime.").unwrap();
writeln!(output, " For type-safe access, use the `metrics` tree instead.").unwrap();
writeln!(output, " \"\"\"").unwrap();
writeln!(output, " return MetricEndpointBuilder(self, metric, index)").unwrap();
writeln!(output).unwrap();
// Generate helper methods
writeln!(output, " def index_to_date(self, index: Index, i: int) -> date:").unwrap();
writeln!(output, " \"\"\"Convert an index value to a date for date-based indexes.\"\"\"").unwrap();
writeln!(output, " return index_to_date(index, i)").unwrap();
writeln!(output).unwrap();
writeln!(output, " def is_date_index(self, index: Index) -> bool:").unwrap();
writeln!(output, " \"\"\"Check if an index type is date-based.\"\"\"").unwrap();
writeln!(output, " return is_date_index(index)").unwrap();
writeln!(output).unwrap();
// Generate API methods
generate_api_methods(output, endpoints);
}
/// Generate API methods from OpenAPI endpoints
pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
for endpoint in endpoints {
if !endpoint.should_generate() {
continue;
}
let method_name = endpoint_to_method_name(endpoint);
let base_return_type = normalize_return_type(
&endpoint
.response_type
.as_deref()
.map(js_type_to_python)
.unwrap_or_else(|| "Any".to_string()),
);
let return_type = if endpoint.supports_csv {
format!("Union[{}, str]", base_return_type)
} else {
base_return_type
};
// Build method signature
let params = build_method_params(endpoint);
writeln!(
output,
" def {}(self{}) -> {}:",
method_name, params, return_type
)
.unwrap();
// Docstring
match (&endpoint.summary, &endpoint.description) {
(Some(summary), Some(desc)) if summary != desc => {
writeln!(output, " \"\"\"{}.", summary.trim_end_matches('.')).unwrap();
writeln!(output).unwrap();
write_description(output, desc, " ", "");
}
(Some(summary), _) => {
writeln!(output, " \"\"\"{}", summary).unwrap();
}
(None, Some(desc)) => {
// First line includes opening quotes
let mut lines = desc.lines();
if let Some(first) = lines.next() {
writeln!(output, " \"\"\"{}", first).unwrap();
}
for line in lines {
if line.is_empty() {
writeln!(output).unwrap();
} else {
writeln!(output, " {}", line).unwrap();
}
}
}
(None, None) => {
write!(output, " \"\"\"").unwrap();
}
}
writeln!(output).unwrap();
writeln!(output, " Endpoint: `{} {}`\"\"\"", endpoint.method.to_uppercase(), endpoint.path).unwrap();
// Build path
let path = build_path_template(&endpoint.path, &endpoint.path_params);
if endpoint.query_params.is_empty() {
if endpoint.path_params.is_empty() {
writeln!(output, " return self.get_json('{}')", path).unwrap();
} else {
writeln!(output, " return self.get_json(f'{}')", path).unwrap();
}
} else {
writeln!(output, " params = []").unwrap();
for param in &endpoint.query_params {
// Use safe name for Python variable, original name for API query parameter
let safe_name = escape_python_keyword(&param.name);
if param.required {
writeln!(
output,
" params.append(f'{}={{{}}}')",
param.name, safe_name
)
.unwrap();
} else {
writeln!(
output,
" if {} is not None: params.append(f'{}={{{}}}')",
safe_name, param.name, safe_name
)
.unwrap();
}
}
writeln!(output, " query = '&'.join(params)").unwrap();
writeln!(
output,
" path = f'{}{{\"?\" + query if query else \"\"}}'",
path
)
.unwrap();
if endpoint.supports_csv {
writeln!(output, " if format == 'csv':").unwrap();
writeln!(output, " return self.get_text(path)").unwrap();
writeln!(output, " return self.get_json(path)").unwrap();
} else {
writeln!(output, " return self.get_json(path)").unwrap();
}
}
writeln!(output).unwrap();
}
}
fn endpoint_to_method_name(endpoint: &Endpoint) -> String {
to_snake_case(&endpoint.operation_name())
}
fn build_method_params(endpoint: &Endpoint) -> String {
let mut params = Vec::new();
// Path params are always required
for param in &endpoint.path_params {
let safe_name = escape_python_keyword(&param.name);
let py_type = js_type_to_python(&param.param_type);
params.push(format!(", {}: {}", safe_name, py_type));
}
// Required query params must come before optional ones (Python syntax requirement)
for param in &endpoint.query_params {
if param.required {
let safe_name = escape_python_keyword(&param.name);
let py_type = js_type_to_python(&param.param_type);
params.push(format!(", {}: {}", safe_name, py_type));
}
}
for param in &endpoint.query_params {
if !param.required {
let safe_name = escape_python_keyword(&param.name);
let py_type = js_type_to_python(&param.param_type);
params.push(format!(", {}: Optional[{}] = None", safe_name, py_type));
}
}
params.join("")
}
fn build_path_template(path: &str, path_params: &[Parameter]) -> String {
let mut result = path.to_string();
for param in path_params {
let placeholder = format!("{{{}}}", param.name);
// Use escaped name for Python variable interpolation in f-string
let safe_name = escape_python_keyword(&param.name);
let interpolation = format!("{{{}}}", safe_name);
result = result.replace(&placeholder, &interpolation);
}
result
}
@@ -0,0 +1,574 @@
//! Python base client and pattern factory generation.
use std::fmt::Write;
use crate::{
ClientConstants, ClientMetadata, CohortConstants, IndexSetPattern, PythonSyntax,
StructuralPattern, format_json, generate_parameterized_field, index_to_field_name,
};
/// Generate class-level constants for the BrkClient class.
pub fn generate_class_constants(output: &mut String) {
let constants = ClientConstants::collect();
// VERSION
writeln!(output, " VERSION = \"{}\"\n", constants.version).unwrap();
// INDEXES, POOL_ID_TO_POOL_NAME
write_class_const(output, "INDEXES", &format_json(&constants.indexes));
// Python needs string keys for pool map
let pool_map: std::collections::BTreeMap<String, &str> = constants
.pool_map
.iter()
.map(|(k, v)| (k.to_string(), *v))
.collect();
write_class_const(output, "POOL_ID_TO_POOL_NAME", &format_json(&pool_map));
// Cohort constants (no camelCase conversion for Python)
for (name, value) in CohortConstants::all() {
write_class_const(output, name, &format_json(&value));
}
}
fn write_class_const(output: &mut String, name: &str, json: &str) {
let indented = json
.lines()
.enumerate()
.map(|(i, line)| {
if i == 0 {
format!(" {} = {}", name, line)
} else {
format!(" {}", line)
}
})
.collect::<Vec<_>>()
.join("\n");
writeln!(output, "{}\n", indented).unwrap();
}
/// Generate the base BrkClient class with HTTP functionality
pub fn generate_base_client(output: &mut String) {
writeln!(
output,
r#"class BrkError(Exception):
"""Custom error class for BRK client errors."""
def __init__(self, message: str, status: Optional[int] = None):
super().__init__(message)
self.status = status
class BrkClientBase:
"""Base HTTP client for making requests."""
def __init__(self, base_url: str, timeout: float = 30.0):
parsed = urlparse(base_url)
self._host = parsed.netloc
self._secure = parsed.scheme == 'https'
self._timeout = timeout
self._conn: Optional[Union[HTTPSConnection, HTTPConnection]] = None
def _connect(self) -> Union[HTTPSConnection, HTTPConnection]:
"""Get or create HTTP connection."""
if self._conn is None:
if self._secure:
self._conn = HTTPSConnection(self._host, timeout=self._timeout)
else:
self._conn = HTTPConnection(self._host, timeout=self._timeout)
return self._conn
def get(self, path: str) -> bytes:
"""Make a GET request and return raw bytes."""
try:
conn = self._connect()
conn.request("GET", path)
res = conn.getresponse()
data = res.read()
if res.status >= 400:
raise BrkError(f"HTTP error: {{res.status}}", res.status)
return data
except (ConnectionError, OSError, TimeoutError) as e:
self._conn = None
raise BrkError(str(e))
def get_json(self, path: str) -> Any:
"""Make a GET request and return JSON."""
return json.loads(self.get(path))
def get_text(self, path: str) -> str:
"""Make a GET request and return text."""
return self.get(path).decode()
def close(self):
"""Close the HTTP client."""
if self._conn:
self._conn.close()
self._conn = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def _m(acc: str, s: str) -> str:
"""Build metric name with suffix."""
if not s: return acc
return f"{{acc}}_{{s}}" if acc else s
def _p(prefix: str, acc: str) -> str:
"""Build metric name with prefix."""
return f"{{prefix}}_{{acc}}" if acc else prefix
"#
)
.unwrap();
}
/// Generate the MetricData and MetricEndpointBuilder classes
pub fn generate_endpoint_class(output: &mut String) {
writeln!(
output,
r#"# Date conversion constants
_GENESIS = date(2009, 1, 3) # dateindex 0, weekindex 0
_DAY_ONE = date(2009, 1, 9) # dateindex 1 (6 day gap after genesis)
_DATE_INDEXES = frozenset(['dateindex', 'weekindex', 'monthindex', 'yearindex', 'quarterindex', 'semesterindex', 'decadeindex'])
def is_date_index(index: str) -> bool:
"""Check if an index type is date-based."""
return index in _DATE_INDEXES
def index_to_date(index: str, i: int) -> date:
"""Convert an index value to a date for date-based indexes."""
if index == 'dateindex':
return _GENESIS if i == 0 else _DAY_ONE + timedelta(days=i - 1)
elif index == 'weekindex':
return _GENESIS + timedelta(weeks=i)
elif index == 'monthindex':
return date(2009 + i // 12, i % 12 + 1, 1)
elif index == 'yearindex':
return date(2009 + i, 1, 1)
elif index == 'quarterindex':
m = i * 3
return date(2009 + m // 12, m % 12 + 1, 1)
elif index == 'semesterindex':
m = i * 6
return date(2009 + m // 12, m % 12 + 1, 1)
elif index == 'decadeindex':
return date(2009 + i * 10, 1, 1)
else:
raise ValueError(f"{{index}} is not a date-based index")
@dataclass
class MetricData(Generic[T]):
"""Metric data with range information."""
version: int
index: Index
total: int
start: int
end: int
stamp: str
data: List[T]
def dates(self) -> List[date]:
"""Convert index range to dates. Only works for date-based indexes."""
return [index_to_date(self.index, i) for i in range(self.start, self.end)]
def indexes(self) -> List[int]:
"""Get index range as list."""
return list(range(self.start, self.end))
def to_date_dict(self) -> dict[date, T]:
"""Return data as {{date: value}} dict. Only works for date-based indexes."""
return dict(zip(self.dates(), self.data))
def to_index_dict(self) -> dict[int, T]:
"""Return data as {{index: value}} dict."""
return dict(zip(range(self.start, self.end), self.data))
def date_items(self) -> List[Tuple[date, T]]:
"""Return data as [(date, value), ...] pairs. Only works for date-based indexes."""
return list(zip(self.dates(), self.data))
def index_items(self) -> List[Tuple[int, T]]:
"""Return data as [(index, value), ...] pairs."""
return list(zip(range(self.start, self.end), self.data))
def iter(self) -> Iterator[Tuple[int, T]]:
"""Iterate over (index, value) pairs."""
return iter(zip(range(self.start, self.end), self.data))
def iter_dates(self) -> Iterator[Tuple[date, T]]:
"""Iterate over (date, value) pairs. Date-based indexes only."""
return iter(zip(self.dates(), self.data))
def __iter__(self) -> Iterator[Tuple[int, T]]:
"""Default iteration over (index, value) pairs."""
return self.iter()
def to_polars(self, with_dates: bool = True) -> pl.DataFrame:
"""Convert to Polars DataFrame. Requires polars to be installed.
Returns a DataFrame with columns:
- 'date' (date) and 'value' (T) if with_dates=True and index is date-based
- 'index' (int) and 'value' (T) otherwise
"""
try:
import polars as pl # type: ignore[import-not-found]
except ImportError:
raise ImportError("polars is required: pip install polars")
if with_dates and self.index in _DATE_INDEXES:
return pl.DataFrame({{"date": self.dates(), "value": self.data}})
return pl.DataFrame({{"index": list(range(self.start, self.end)), "value": self.data}})
def to_pandas(self, with_dates: bool = True) -> pd.DataFrame:
"""Convert to Pandas DataFrame. Requires pandas to be installed.
Returns a DataFrame with columns:
- 'date' (date) and 'value' (T) if with_dates=True and index is date-based
- 'index' (int) and 'value' (T) otherwise
"""
try:
import pandas as pd # type: ignore[import-not-found]
except ImportError:
raise ImportError("pandas is required: pip install pandas")
if with_dates and self.index in _DATE_INDEXES:
return pd.DataFrame({{"date": self.dates(), "value": self.data}})
return pd.DataFrame({{"index": list(range(self.start, self.end)), "value": self.data}})
# Type alias for non-generic usage
AnyMetricData = MetricData[Any]
class _EndpointConfig:
"""Shared endpoint configuration."""
client: BrkClientBase
name: str
index: Index
start: Optional[int]
end: Optional[int]
def __init__(self, client: BrkClientBase, name: str, index: Index,
start: Optional[int] = None, end: Optional[int] = None):
self.client = client
self.name = name
self.index = index
self.start = start
self.end = end
def path(self) -> str:
return f"/api/metric/{{self.name}}/{{self.index}}"
def _build_path(self, format: Optional[str] = None) -> str:
params = []
if self.start is not None:
params.append(f"start={{self.start}}")
if self.end is not None:
params.append(f"end={{self.end}}")
if format is not None:
params.append(f"format={{format}}")
query = "&".join(params)
p = self.path()
return f"{{p}}?{{query}}" if query else p
def get_metric(self) -> MetricData:
return MetricData(**self.client.get_json(self._build_path()))
def get_csv(self) -> str:
return self.client.get_text(self._build_path(format='csv'))
class RangeBuilder(Generic[T]):
"""Builder with range specified."""
def __init__(self, config: _EndpointConfig):
self._config = config
def fetch(self) -> MetricData[T]:
"""Fetch the range as parsed JSON."""
return self._config.get_metric()
def fetch_csv(self) -> str:
"""Fetch the range as CSV string."""
return self._config.get_csv()
class SingleItemBuilder(Generic[T]):
"""Builder for single item access."""
def __init__(self, config: _EndpointConfig):
self._config = config
def fetch(self) -> MetricData[T]:
"""Fetch the single item."""
return self._config.get_metric()
def fetch_csv(self) -> str:
"""Fetch as CSV."""
return self._config.get_csv()
class SkippedBuilder(Generic[T]):
"""Builder after calling skip(n). Chain with take() to specify count."""
def __init__(self, config: _EndpointConfig):
self._config = config
def take(self, n: int) -> RangeBuilder[T]:
"""Take n items after the skipped position."""
start = self._config.start or 0
return RangeBuilder(_EndpointConfig(
self._config.client, self._config.name, self._config.index,
start, start + n
))
def fetch(self) -> MetricData[T]:
"""Fetch from skipped position to end."""
return self._config.get_metric()
def fetch_csv(self) -> str:
"""Fetch as CSV."""
return self._config.get_csv()
class MetricEndpointBuilder(Generic[T]):
"""Builder for metric endpoint queries.
Use method chaining to specify the data range, then call fetch() or fetch_csv() to execute.
Examples:
# Fetch all data
data = endpoint.fetch()
# Single item access
data = endpoint[5].fetch()
# Slice syntax (Python-native)
data = endpoint[:10].fetch() # First 10
data = endpoint[-5:].fetch() # Last 5
data = endpoint[100:110].fetch() # Range
# Convenience methods (pandas-style)
data = endpoint.head().fetch() # First 10 (default)
data = endpoint.head(20).fetch() # First 20
data = endpoint.tail(5).fetch() # Last 5
# Iterator-style chaining
data = endpoint.skip(100).take(10).fetch()
"""
def __init__(self, client: BrkClientBase, name: str, index: Index):
self._config = _EndpointConfig(client, name, index)
@overload
def __getitem__(self, key: int) -> SingleItemBuilder[T]: ...
@overload
def __getitem__(self, key: slice) -> RangeBuilder[T]: ...
def __getitem__(self, key: Union[int, slice]) -> Union[SingleItemBuilder[T], RangeBuilder[T]]:
"""Access single item or slice.
Examples:
endpoint[5] # Single item at index 5
endpoint[:10] # First 10
endpoint[-5:] # Last 5
endpoint[100:110] # Range 100-109
"""
if isinstance(key, int):
return SingleItemBuilder(_EndpointConfig(
self._config.client, self._config.name, self._config.index,
key, key + 1
))
return RangeBuilder(_EndpointConfig(
self._config.client, self._config.name, self._config.index,
key.start, key.stop
))
def head(self, n: int = 10) -> RangeBuilder[T]:
"""Get the first n items (pandas-style)."""
return RangeBuilder(_EndpointConfig(
self._config.client, self._config.name, self._config.index,
None, n
))
def tail(self, n: int = 10) -> RangeBuilder[T]:
"""Get the last n items (pandas-style)."""
start, end = (None, 0) if n == 0 else (-n, None)
return RangeBuilder(_EndpointConfig(
self._config.client, self._config.name, self._config.index,
start, end
))
def skip(self, n: int) -> SkippedBuilder[T]:
"""Skip the first n items. Chain with take() to get a range."""
return SkippedBuilder(_EndpointConfig(
self._config.client, self._config.name, self._config.index,
n, None
))
def fetch(self) -> MetricData[T]:
"""Fetch all data as parsed JSON."""
return self._config.get_metric()
def fetch_csv(self) -> str:
"""Fetch all data as CSV string."""
return self._config.get_csv()
def path(self) -> str:
"""Get the base endpoint path."""
return self._config.path()
# Type alias for non-generic usage
AnyMetricEndpointBuilder = MetricEndpointBuilder[Any]
class MetricPattern(Protocol[T]):
"""Protocol for metric patterns with different index sets."""
@property
def name(self) -> str:
"""Get the metric name."""
...
def indexes(self) -> List[str]:
"""Get the list of available indexes for this metric."""
...
def get(self, index: Index) -> Optional[MetricEndpointBuilder[T]]:
"""Get an endpoint builder for a specific index, if supported."""
...
"#
)
.unwrap();
}
/// Generate index accessor classes
pub fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) {
if patterns.is_empty() {
return;
}
// Generate static index tuples
writeln!(output, "# Static index tuples").unwrap();
for (i, pattern) in patterns.iter().enumerate() {
write!(output, "_i{} = (", i + 1).unwrap();
for (j, index) in pattern.indexes.iter().enumerate() {
if j > 0 {
write!(output, ", ").unwrap();
}
write!(output, "'{}'", index.serialize_long()).unwrap();
}
// Single-element tuple needs trailing comma
if pattern.indexes.len() == 1 {
write!(output, ",").unwrap();
}
writeln!(output, ")").unwrap();
}
writeln!(output).unwrap();
// Generate helper function
writeln!(
output,
r#"def _ep(c: BrkClientBase, n: str, i: Index) -> MetricEndpointBuilder[Any]:
return MetricEndpointBuilder(c, n, i)
"#
)
.unwrap();
writeln!(output, "# Index accessor classes\n").unwrap();
for (i, pattern) in patterns.iter().enumerate() {
let by_class_name = format!("_{}By", pattern.name);
let idx_var = format!("_i{}", i + 1);
// Generate the By class with compact methods
writeln!(output, "class {}(Generic[T]):", by_class_name).unwrap();
writeln!(
output,
" def __init__(self, c: BrkClientBase, n: str): self._c, self._n = c, n"
)
.unwrap();
for index in &pattern.indexes {
let method_name = index_to_field_name(index);
let index_name = index.serialize_long();
writeln!(
output,
" def {}(self) -> MetricEndpointBuilder[T]: return _ep(self._c, self._n, '{}')",
method_name, index_name
)
.unwrap();
}
writeln!(output).unwrap();
// Generate the main accessor class
writeln!(output, "class {}(Generic[T]):", pattern.name).unwrap();
writeln!(output, " by: {}[T]", by_class_name).unwrap();
writeln!(
output,
" def __init__(self, c: BrkClientBase, n: str): self._n, self.by = n, {}(c, n)",
by_class_name
)
.unwrap();
writeln!(output, " @property").unwrap();
writeln!(output, " def name(self) -> str: return self._n").unwrap();
writeln!(output, " def indexes(self) -> List[str]: return list({})", idx_var).unwrap();
writeln!(
output,
" def get(self, index: Index) -> Optional[MetricEndpointBuilder[T]]: return _ep(self.by._c, self._n, index) if index in {} else None",
idx_var
)
.unwrap();
writeln!(output).unwrap();
}
}
/// Generate structural pattern classes
pub fn generate_structural_patterns(
output: &mut String,
patterns: &[StructuralPattern],
metadata: &ClientMetadata,
) {
if patterns.is_empty() {
return;
}
writeln!(output, "# Reusable structural pattern classes\n").unwrap();
for pattern in patterns {
// Generate class
if pattern.is_generic {
writeln!(output, "class {}(Generic[T]):", pattern.name).unwrap();
} else {
writeln!(output, "class {}:", pattern.name).unwrap();
}
writeln!(
output,
" \"\"\"Pattern struct for repeated tree structure.\"\"\""
)
.unwrap();
writeln!(output, " ").unwrap();
writeln!(
output,
" def __init__(self, client: BrkClientBase, acc: str):"
)
.unwrap();
writeln!(
output,
" \"\"\"Create pattern node with accumulated metric name.\"\"\""
)
.unwrap();
let syntax = PythonSyntax;
for field in &pattern.fields {
generate_parameterized_field(output, &syntax, field, pattern, metadata, " ");
}
writeln!(output).unwrap();
}
}
@@ -0,0 +1,59 @@
//! Python client generation.
//!
//! This module generates a Python client with type hints for the BRK API.
pub mod api;
pub mod client;
pub mod tree;
pub mod types;
use std::{fmt::Write, io, path::Path};
use super::write_if_changed;
use crate::{ClientMetadata, Endpoint, TypeSchemas};
/// Generate Python client from metadata and OpenAPI endpoints.
///
/// `output_path` is the full path to the output file (e.g., "packages/brk_client/__init__.py").
pub fn generate_python_client(
metadata: &ClientMetadata,
endpoints: &[Endpoint],
schemas: &TypeSchemas,
output_path: &Path,
) -> io::Result<()> {
let mut output = String::new();
writeln!(output, "# Auto-generated BRK Python client").unwrap();
writeln!(output, "# Do not edit manually\n").unwrap();
writeln!(output, "from __future__ import annotations").unwrap();
writeln!(output, "from dataclasses import dataclass").unwrap();
writeln!(
output,
"from typing import TypeVar, Generic, Any, Optional, List, Literal, TypedDict, Union, Protocol, overload, Iterator, Tuple, TYPE_CHECKING"
)
.unwrap();
writeln!(
output,
"from http.client import HTTPSConnection, HTTPConnection"
)
.unwrap();
writeln!(output, "from urllib.parse import urlparse").unwrap();
writeln!(output, "from datetime import date, timedelta").unwrap();
writeln!(output, "import json\n").unwrap();
writeln!(output, "if TYPE_CHECKING:").unwrap();
writeln!(output, " import pandas as pd # type: ignore[import-not-found]").unwrap();
writeln!(output, " import polars as pl # type: ignore[import-not-found]\n").unwrap();
writeln!(output, "T = TypeVar('T')\n").unwrap();
types::generate_type_definitions(&mut output, schemas);
client::generate_base_client(&mut output);
client::generate_endpoint_class(&mut output);
client::generate_index_accessors(&mut output, &metadata.index_set_patterns);
client::generate_structural_patterns(&mut output, &metadata.structural_patterns, metadata);
tree::generate_tree_classes(&mut output, &metadata.catalog, metadata);
api::generate_main_client(&mut output, endpoints);
write_if_changed(output_path, &output)?;
Ok(())
}
@@ -0,0 +1,108 @@
//! Python tree structure generation.
use std::collections::BTreeSet;
use std::fmt::Write;
use brk_types::TreeNode;
use crate::{
ClientMetadata, GenericSyntax, PatternField, PythonSyntax, build_child_path,
generate_leaf_field, prepare_tree_node, to_snake_case,
};
/// Generate tree classes
pub fn generate_tree_classes(output: &mut String, catalog: &TreeNode, metadata: &ClientMetadata) {
writeln!(output, "# Metrics tree classes\n").unwrap();
let pattern_lookup = metadata.pattern_lookup();
let mut generated = BTreeSet::new();
generate_tree_class(
output,
"MetricsTree",
"",
catalog,
&pattern_lookup,
metadata,
&mut generated,
);
}
/// Recursively generate tree classes
fn generate_tree_class(
output: &mut String,
name: &str,
path: &str,
node: &TreeNode,
pattern_lookup: &std::collections::BTreeMap<Vec<PatternField>, String>,
metadata: &ClientMetadata,
generated: &mut BTreeSet<String>,
) {
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
return;
};
// Generate child classes FIRST (post-order traversal)
// This ensures children are defined before parent references them
for child in &ctx.children {
if child.should_inline {
let child_path = build_child_path(path, child.name);
generate_tree_class(
output,
&child.inline_type_name,
&child_path,
child.node,
pattern_lookup,
metadata,
generated,
);
}
}
// THEN generate the current class (after all children are defined)
writeln!(output, "class {}:", name).unwrap();
writeln!(output, " \"\"\"Metrics tree node.\"\"\"").unwrap();
writeln!(output, " ").unwrap();
writeln!(
output,
" def __init__(self, client: BrkClientBase, base_path: str = ''):"
)
.unwrap();
let syntax = PythonSyntax;
for child in &ctx.children {
let field_name_py = to_snake_case(child.name);
if child.is_leaf {
if let TreeNode::Leaf(leaf) = child.node {
generate_leaf_field(
output, &syntax, "client", child.name, leaf, metadata, " ",
);
}
} else if child.should_inline {
// Inline class
writeln!(
output,
" self.{}: {} = {}(client)",
field_name_py, child.inline_type_name, child.inline_type_name
)
.unwrap();
} else {
// Use pattern class with metric base
let py_type = metadata.resolve_tree_field_type(
&child.field,
child.child_fields.as_deref(),
name,
child.name,
GenericSyntax::PYTHON,
);
writeln!(
output,
" self.{}: {} = {}(client, '{}')",
field_name_py, py_type, child.field.rust_type, child.base_result.base
)
.unwrap();
}
}
writeln!(output).unwrap();
}
@@ -0,0 +1,339 @@
//! Python type definitions generation.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write;
use serde_json::Value;
use crate::{
TypeSchemas, escape_python_keyword, generators::MANUAL_GENERIC_TYPES, get_union_variants,
ref_to_type_name,
};
/// Generate type definitions from schemas.
pub fn generate_type_definitions(output: &mut String, schemas: &TypeSchemas) {
if schemas.is_empty() {
return;
}
writeln!(output, "# Type definitions\n").unwrap();
let sorted_names = topological_sort_schemas(schemas);
// Partition into simple type aliases and TypedDict classes
// Generate type aliases first to avoid forward reference issues
let (type_aliases, typed_dicts): (Vec<_>, Vec<_>) = sorted_names
.into_iter()
.filter(|name| !MANUAL_GENERIC_TYPES.contains(&name.as_str()))
.filter(|name| schemas.contains_key(name))
.partition(|name| {
schemas
.get(name)
.map(|s| s.get("properties").is_none())
.unwrap_or(false)
});
// Generate simple type aliases first
// Quote references to TypedDicts since they're defined after
let typed_dict_set: BTreeSet<_> = typed_dicts.iter().cloned().collect();
for name in type_aliases {
let schema = &schemas[&name];
let type_desc = schema.get("description").and_then(|d| d.as_str());
let py_type = schema_to_python_type(schema, Some(&name), Some(&typed_dict_set));
if let Some(desc) = type_desc {
for line in desc.lines() {
writeln!(output, "# {}", line).unwrap();
}
}
writeln!(output, "{} = {}", name, py_type).unwrap();
}
// Then generate TypedDict classes
for name in typed_dicts {
let schema = &schemas[&name];
let type_desc = schema.get("description").and_then(|d| d.as_str());
let props = schema
.get("properties")
.and_then(|p| p.as_object())
.unwrap();
writeln!(output, "class {}(TypedDict):", name).unwrap();
// Collect field descriptions for Attributes section
let field_docs: Vec<(String, Option<&str>)> = props
.iter()
.map(|(prop_name, prop_schema)| {
let safe_name = escape_python_keyword(prop_name);
let desc = prop_schema.get("description").and_then(|d| d.as_str());
(safe_name, desc)
})
.collect();
let has_field_docs = field_docs.iter().any(|(_, d)| d.is_some());
// Generate docstring if we have type description or field descriptions
if type_desc.is_some() || has_field_docs {
writeln!(output, " \"\"\"").unwrap();
if let Some(desc) = type_desc {
for line in desc.lines() {
writeln!(output, " {}", line).unwrap();
}
}
if has_field_docs {
if type_desc.is_some() {
writeln!(output).unwrap();
}
writeln!(output, " Attributes:").unwrap();
for (field_name, desc) in &field_docs {
if let Some(d) = desc {
writeln!(output, " {}: {}", field_name, d).unwrap();
}
}
}
writeln!(output, " \"\"\"").unwrap();
}
for (prop_name, prop_schema) in props {
let prop_type = schema_to_python_type(prop_schema, Some(&name), None);
let safe_name = escape_python_keyword(prop_name);
writeln!(output, " {}: {}", safe_name, prop_type).unwrap();
}
writeln!(output).unwrap();
}
writeln!(output).unwrap();
}
/// Topologically sort schema names so dependencies come before dependents (avoids forward references).
/// Types that reference other types (via $ref) must be defined after their dependencies.
fn topological_sort_schemas(schemas: &TypeSchemas) -> Vec<String> {
// Build dependency graph
let mut deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (name, schema) in schemas {
let mut type_deps = BTreeSet::new();
collect_schema_refs(schema, &mut type_deps);
// Only keep deps that are in our schemas
type_deps.retain(|d| schemas.contains_key(d));
deps.insert(name.clone(), type_deps);
}
// Kahn's algorithm for topological sort
let mut in_degree: BTreeMap<String, usize> = BTreeMap::new();
for name in schemas.keys() {
in_degree.insert(name.clone(), 0);
}
for type_deps in deps.values() {
for dep in type_deps {
*in_degree.entry(dep.clone()).or_insert(0) += 1;
}
}
// Start with types that have no dependents (are not referenced by others)
let mut queue: Vec<String> = in_degree
.iter()
.filter(|(_, count)| **count == 0)
.map(|(name, _)| name.clone())
.collect();
queue.sort(); // Deterministic order
let mut result = Vec::new();
while let Some(name) = queue.pop() {
result.push(name.clone());
if let Some(type_deps) = deps.get(&name) {
for dep in type_deps {
if let Some(count) = in_degree.get_mut(dep) {
*count = count.saturating_sub(1);
if *count == 0 {
queue.push(dep.clone());
queue.sort(); // Keep sorted for determinism
}
}
}
}
}
// Reverse so dependencies come first
result.reverse();
// Add any types that weren't processed (e.g., due to circular refs or other edge cases)
let result_set: BTreeSet<_> = result.iter().cloned().collect();
let mut missing: Vec<_> = schemas
.keys()
.filter(|k| !result_set.contains(*k))
.cloned()
.collect();
missing.sort();
result.extend(missing);
result
}
/// Collect all type references ($ref) from a schema
fn collect_schema_refs(schema: &Value, refs: &mut BTreeSet<String>) {
match schema {
Value::Object(map) => {
if let Some(ref_path) = map.get("$ref").and_then(|r| r.as_str())
&& let Some(type_name) = ref_to_type_name(ref_path)
{
refs.insert(type_name.to_string());
}
for value in map.values() {
collect_schema_refs(value, refs);
}
}
Value::Array(arr) => {
for item in arr {
collect_schema_refs(item, refs);
}
}
_ => {}
}
}
/// Convert a single JSON type string to Python type
fn json_type_to_python(ty: &str, schema: &Value, current_type: Option<&str>) -> String {
match ty {
"integer" => "int".to_string(),
"number" => "float".to_string(),
"boolean" => "bool".to_string(),
"string" => "str".to_string(),
"null" => "None".to_string(),
"array" => {
let item_type = schema
.get("items")
.map(|s| schema_to_python_type(s, current_type, None))
.unwrap_or_else(|| "Any".to_string());
format!("List[{}]", item_type)
}
"object" => {
if let Some(add_props) = schema.get("additionalProperties") {
let value_type = schema_to_python_type(add_props, current_type, None);
return format!("dict[str, {}]", value_type);
}
"dict".to_string()
}
_ => "Any".to_string(),
}
}
/// Convert JSON Schema to Python type.
///
/// - `current_type`: Used to detect and quote self-references for recursive types
/// - `quote_types`: Optional set of additional type names that should be quoted
pub fn schema_to_python_type(
schema: &Value,
current_type: Option<&str>,
quote_types: Option<&BTreeSet<String>>,
) -> String {
if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array()) {
for item in all_of {
let resolved = schema_to_python_type(item, current_type, quote_types);
if resolved != "Any" {
return resolved;
}
}
}
// Handle $ref
if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
let type_name = ref_to_type_name(ref_path).unwrap_or("Any");
// Quote self-references or types in quote_types set
let should_quote =
current_type == Some(type_name) || quote_types.is_some_and(|qt| qt.contains(type_name));
if should_quote {
return format!("\"{}\"", type_name);
}
return type_name.to_string();
}
// Handle enum (array of string values)
if let Some(enum_values) = schema.get("enum").and_then(|e| e.as_array()) {
let literals: Vec<String> = enum_values
.iter()
.filter_map(|v| v.as_str())
.map(|s| format!("\"{}\"", s))
.collect();
if !literals.is_empty() {
return format!("Literal[{}]", literals.join(", "));
}
}
if let Some(ty) = schema.get("type") {
if let Some(type_array) = ty.as_array() {
let types: Vec<String> = type_array
.iter()
.filter_map(|t| t.as_str())
.filter(|t| *t != "null")
.map(|t| json_type_to_python(t, schema, current_type))
.collect();
let has_null = type_array.iter().any(|t| t.as_str() == Some("null"));
if types.len() == 1 {
let base_type = &types[0];
return if has_null {
format!("Optional[{}]", base_type)
} else {
base_type.clone()
};
} else if !types.is_empty() {
let union = format!("Union[{}]", types.join(", "));
return if has_null {
format!("Optional[{}]", union)
} else {
union
};
}
}
if let Some(ty_str) = ty.as_str() {
return json_type_to_python(ty_str, schema, current_type);
}
}
if let Some(variants) = get_union_variants(schema) {
let types: Vec<String> = variants
.iter()
.map(|v| schema_to_python_type(v, current_type, quote_types))
.collect();
let filtered: Vec<_> = types.iter().filter(|t| *t != "Any").collect();
if !filtered.is_empty() {
return format!(
"Union[{}]",
filtered
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
return format!("Union[{}]", types.join(", "));
}
// Check for format hint without type (common in OpenAPI)
if let Some(format) = schema.get("format").and_then(|f| f.as_str()) {
return match format {
"int32" | "int64" => "int".to_string(),
"float" | "double" => "float".to_string(),
"date" | "date-time" => "str".to_string(),
_ => "Any".to_string(),
};
}
"Any".to_string()
}
/// Convert JS-style type to Python type (e.g., "Txid[]" -> "List[Txid]", "integer" -> "int")
pub fn js_type_to_python(js_type: &str) -> String {
if let Some(inner) = js_type.strip_suffix("[]") {
format!("List[{}]", js_type_to_python(inner))
} else {
match js_type {
"integer" => "int".to_string(),
"number" => "float".to_string(),
"boolean" => "bool".to_string(),
"string" => "str".to_string(),
"null" => "None".to_string(),
"Object" | "object" => "dict".to_string(),
"*" => "Any".to_string(),
_ => js_type.to_string(),
}
}
}
@@ -0,0 +1,194 @@
//! Rust API method generation.
use std::fmt::Write;
use crate::{Endpoint, VERSION, generators::write_description, to_snake_case};
use super::types::js_type_to_rust;
/// Generate the main BrkClient struct.
pub fn generate_main_client(output: &mut String, endpoints: &[Endpoint]) {
writeln!(
output,
r#"/// Main BRK client with metrics tree and API methods.
pub struct BrkClient {{
base: Arc<BrkClientBase>,
metrics: MetricsTree,
}}
impl BrkClient {{
/// Client version.
pub const VERSION: &'static str = "v{VERSION}";
/// Create a new client with the given base URL.
pub fn new(base_url: impl Into<String>) -> Self {{
let base = Arc::new(BrkClientBase::new(base_url));
let metrics = MetricsTree::new(base.clone(), String::new());
Self {{ base, metrics }}
}}
/// Create a new client with options.
pub fn with_options(options: BrkClientOptions) -> Self {{
let base = Arc::new(BrkClientBase::with_options(options));
let metrics = MetricsTree::new(base.clone(), String::new());
Self {{ base, metrics }}
}}
/// Get the metrics tree for navigating metrics.
pub fn metrics(&self) -> &MetricsTree {{
&self.metrics
}}
/// Create a dynamic metric endpoint builder for any metric/index combination.
///
/// Use this for programmatic access when the metric name is determined at runtime.
/// For type-safe access, use the `metrics()` tree instead.
///
/// # Example
/// ```ignore
/// let data = client.metric("realized_price", Index::Height)
/// .last(10)
/// .json::<f64>()?;
/// ```
pub fn metric(&self, metric: impl Into<Metric>, index: Index) -> MetricEndpointBuilder<serde_json::Value> {{
MetricEndpointBuilder::new(
self.base.clone(),
Arc::from(metric.into().as_str()),
index,
)
}}
"#,
VERSION = VERSION
)
.unwrap();
generate_api_methods(output, endpoints);
writeln!(output, "}}").unwrap();
}
/// Generate API methods from OpenAPI endpoints.
pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
for endpoint in endpoints {
if !endpoint.should_generate() {
continue;
}
let method_name = endpoint_to_method_name(endpoint);
let base_return_type = endpoint
.response_type
.as_deref()
.map(js_type_to_rust)
.unwrap_or_else(|| "serde_json::Value".to_string());
let return_type = if endpoint.supports_csv {
format!("FormatResponse<{}>", base_return_type)
} else {
base_return_type.clone()
};
writeln!(
output,
" /// {}",
endpoint.summary.as_deref().unwrap_or(&method_name)
)
.unwrap();
if let Some(desc) = &endpoint.description
&& endpoint.summary.as_ref() != Some(desc)
{
writeln!(output, " ///").unwrap();
write_description(output, desc, " /// ", " ///");
}
// Add endpoint path
writeln!(output, " ///").unwrap();
writeln!(output, " /// Endpoint: `{} {}`", endpoint.method.to_uppercase(), endpoint.path).unwrap();
let params = build_method_params(endpoint);
writeln!(
output,
" pub fn {}(&self{}) -> Result<{}> {{",
method_name, params, return_type
)
.unwrap();
let (path, index_arg) = build_path_template(endpoint);
if endpoint.query_params.is_empty() {
writeln!(output, " self.base.get_json(&format!(\"{}\"{}))", path, index_arg).unwrap();
} else {
writeln!(output, " let mut query = Vec::new();").unwrap();
for param in &endpoint.query_params {
if param.required {
writeln!(
output,
" query.push(format!(\"{}={{}}\", {}));",
param.name, param.name
)
.unwrap();
} else {
writeln!(
output,
" if let Some(v) = {} {{ query.push(format!(\"{}={{}}\", v)); }}",
param.name, param.name
)
.unwrap();
}
}
writeln!(output, " let query_str = if query.is_empty() {{ String::new() }} else {{ format!(\"?{{}}\", query.join(\"&\")) }};").unwrap();
writeln!(output, " let path = format!(\"{}{{}}\"{}, query_str);", path, index_arg).unwrap();
if endpoint.supports_csv {
writeln!(output, " if format == Some(Format::CSV) {{").unwrap();
writeln!(output, " self.base.get_text(&path).map(FormatResponse::Csv)").unwrap();
writeln!(output, " }} else {{").unwrap();
writeln!(output, " self.base.get_json(&path).map(FormatResponse::Json)").unwrap();
writeln!(output, " }}").unwrap();
} else {
writeln!(output, " self.base.get_json(&path)").unwrap();
}
}
writeln!(output, " }}\n").unwrap();
}
}
fn endpoint_to_method_name(endpoint: &Endpoint) -> String {
to_snake_case(&endpoint.operation_name())
}
fn build_method_params(endpoint: &Endpoint) -> String {
let mut params = Vec::new();
for param in &endpoint.path_params {
let rust_type = param_type_to_rust(&param.param_type);
params.push(format!(", {}: {}", param.name, rust_type));
}
for param in &endpoint.query_params {
let rust_type = param_type_to_rust(&param.param_type);
if param.required {
params.push(format!(", {}: {}", param.name, rust_type));
} else {
params.push(format!(", {}: Option<{}>", param.name, rust_type));
}
}
params.join("")
}
/// Convert parameter type to Rust type for function signatures.
fn param_type_to_rust(param_type: &str) -> String {
match param_type {
"string" | "*" => "&str".to_string(),
"integer" | "number" => "i64".to_string(),
"boolean" => "bool".to_string(),
other => other.to_string(), // Domain types like Index, Metric, Format
}
}
/// Build path template and extra format args for Index params.
fn build_path_template(endpoint: &Endpoint) -> (String, &'static str) {
let has_index_param = endpoint.path_params.iter().any(|p| p.name == "index" && p.param_type == "Index");
if has_index_param {
(endpoint.path.replace("{index}", "{}"), ", index.serialize_long()")
} else {
(endpoint.path.clone(), "")
}
}
@@ -0,0 +1,525 @@
//! Rust base client and pattern factory generation.
use std::fmt::Write;
use crate::{
ClientMetadata, GenericSyntax, IndexSetPattern, RustSyntax, StructuralPattern,
generate_parameterized_field, index_to_field_name, to_snake_case,
};
/// Generate import statements.
pub fn generate_imports(output: &mut String) {
writeln!(
output,
r#"use std::sync::Arc;
use std::ops::{{Bound, RangeBounds}};
use serde::de::DeserializeOwned;
pub use brk_cohort::*;
pub use brk_types::*;
"#
)
.unwrap();
}
/// Generate the base BrkClientBase struct and error types.
pub fn generate_base_client(output: &mut String) {
writeln!(
output,
r#"/// Error type for BRK client operations.
#[derive(Debug)]
pub struct BrkError {{
pub message: String,
}}
impl std::fmt::Display for BrkError {{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {{
write!(f, "{{}}", self.message)
}}
}}
impl std::error::Error for BrkError {{}}
/// Result type for BRK client operations.
pub type Result<T> = std::result::Result<T, BrkError>;
/// Options for configuring the BRK client.
#[derive(Debug, Clone)]
pub struct BrkClientOptions {{
pub base_url: String,
pub timeout_secs: u64,
}}
impl Default for BrkClientOptions {{
fn default() -> Self {{
Self {{
base_url: "http://localhost:3000".to_string(),
timeout_secs: 30,
}}
}}
}}
/// Base HTTP client for making requests.
#[derive(Debug, Clone)]
pub struct BrkClientBase {{
base_url: String,
timeout_secs: u64,
}}
impl BrkClientBase {{
/// Create a new client with the given base URL.
pub fn new(base_url: impl Into<String>) -> Self {{
Self {{
base_url: base_url.into(),
timeout_secs: 30,
}}
}}
/// Create a new client with options.
pub fn with_options(options: BrkClientOptions) -> Self {{
Self {{
base_url: options.base_url,
timeout_secs: options.timeout_secs,
}}
}}
fn get(&self, path: &str) -> Result<minreq::Response> {{
let base = self.base_url.trim_end_matches('/');
let url = format!("{{}}{{}}", base, path);
let response = minreq::get(&url)
.with_timeout(self.timeout_secs)
.send()
.map_err(|e| BrkError {{ message: e.to_string() }})?;
if response.status_code >= 400 {{
return Err(BrkError {{
message: format!("HTTP {{}}", response.status_code),
}});
}}
Ok(response)
}}
/// Make a GET request and deserialize JSON response.
pub fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {{
self.get(path)?
.json()
.map_err(|e| BrkError {{ message: e.to_string() }})
}}
/// Make a GET request and return raw text response.
pub fn get_text(&self, path: &str) -> Result<String> {{
self.get(path)?
.as_str()
.map(|s| s.to_string())
.map_err(|e| BrkError {{ message: e.to_string() }})
}}
}}
/// Build metric name with suffix.
#[inline]
fn _m(acc: &str, s: &str) -> String {{
if s.is_empty() {{ acc.to_string() }}
else if acc.is_empty() {{ s.to_string() }}
else {{ format!("{{acc}}_{{s}}") }}
}}
/// Build metric name with prefix.
#[inline]
fn _p(prefix: &str, acc: &str) -> String {{
if acc.is_empty() {{ prefix.to_string() }} else {{ format!("{{prefix}}_{{acc}}") }}
}}
"#
)
.unwrap();
}
/// Generate the MetricPattern trait.
pub fn generate_metric_pattern_trait(output: &mut String) {
writeln!(
output,
r#"/// Non-generic trait for metric patterns (usable in collections).
pub trait AnyMetricPattern {{
/// Get the metric name.
fn name(&self) -> &str;
/// Get the list of available indexes for this metric.
fn indexes(&self) -> &'static [Index];
}}
/// Generic trait for metric patterns with endpoint access.
pub trait MetricPattern<T>: AnyMetricPattern {{
/// Get an endpoint builder for a specific index, if supported.
fn get(&self, index: Index) -> Option<MetricEndpointBuilder<T>>;
}}
"#
)
.unwrap();
}
/// Generate the MetricEndpointBuilder structs with typestate pattern.
pub fn generate_endpoint(output: &mut String) {
writeln!(
output,
r#"/// Shared endpoint configuration.
#[derive(Clone)]
struct EndpointConfig {{
client: Arc<BrkClientBase>,
name: Arc<str>,
index: Index,
start: Option<i64>,
end: Option<i64>,
}}
impl EndpointConfig {{
fn new(client: Arc<BrkClientBase>, name: Arc<str>, index: Index) -> Self {{
Self {{ client, name, index, start: None, end: None }}
}}
fn path(&self) -> String {{
format!("/api/metric/{{}}/{{}}", self.name, self.index.serialize_long())
}}
fn build_path(&self, format: Option<&str>) -> String {{
let mut params = Vec::new();
if let Some(s) = self.start {{ params.push(format!("start={{}}", s)); }}
if let Some(e) = self.end {{ params.push(format!("end={{}}", e)); }}
if let Some(fmt) = format {{ params.push(format!("format={{}}", fmt)); }}
let p = self.path();
if params.is_empty() {{ p }} else {{ format!("{{}}?{{}}", p, params.join("&")) }}
}}
fn get_json<T: DeserializeOwned>(&self, format: Option<&str>) -> Result<T> {{
self.client.get_json(&self.build_path(format))
}}
fn get_text(&self, format: Option<&str>) -> Result<String> {{
self.client.get_text(&self.build_path(format))
}}
}}
/// Initial builder for metric endpoint queries.
///
/// Use method chaining to specify the data range, then call `fetch()` or `fetch_csv()` to execute.
///
/// # Examples
/// ```ignore
/// // Fetch all data
/// let data = endpoint.fetch()?;
///
/// // Get single item at index 5
/// let data = endpoint.get(5).fetch()?;
///
/// // Get first 10 using range
/// let data = endpoint.range(..10).fetch()?;
///
/// // Get range [100, 200)
/// let data = endpoint.range(100..200).fetch()?;
///
/// // Get first 10 (convenience)
/// let data = endpoint.take(10).fetch()?;
///
/// // Get last 10
/// let data = endpoint.last(10).fetch()?;
///
/// // Iterator-style chaining
/// let data = endpoint.skip(100).take(10).fetch()?;
/// ```
pub struct MetricEndpointBuilder<T> {{
config: EndpointConfig,
_marker: std::marker::PhantomData<T>,
}}
impl<T: DeserializeOwned> MetricEndpointBuilder<T> {{
pub fn new(client: Arc<BrkClientBase>, name: Arc<str>, index: Index) -> Self {{
Self {{ config: EndpointConfig::new(client, name, index), _marker: std::marker::PhantomData }}
}}
/// Select a specific index position.
pub fn get(mut self, index: usize) -> SingleItemBuilder<T> {{
self.config.start = Some(index as i64);
self.config.end = Some(index as i64 + 1);
SingleItemBuilder {{ config: self.config, _marker: std::marker::PhantomData }}
}}
/// Select a range using Rust range syntax.
///
/// # Examples
/// ```ignore
/// endpoint.range(..10) // first 10
/// endpoint.range(100..110) // indices 100-109
/// endpoint.range(100..) // from 100 to end
/// ```
pub fn range<R: RangeBounds<usize>>(mut self, range: R) -> RangeBuilder<T> {{
self.config.start = match range.start_bound() {{
Bound::Included(&n) => Some(n as i64),
Bound::Excluded(&n) => Some(n as i64 + 1),
Bound::Unbounded => None,
}};
self.config.end = match range.end_bound() {{
Bound::Included(&n) => Some(n as i64 + 1),
Bound::Excluded(&n) => Some(n as i64),
Bound::Unbounded => None,
}};
RangeBuilder {{ config: self.config, _marker: std::marker::PhantomData }}
}}
/// Take the first n items.
pub fn take(self, n: usize) -> RangeBuilder<T> {{
self.range(..n)
}}
/// Take the last n items.
pub fn last(mut self, n: usize) -> RangeBuilder<T> {{
if n == 0 {{
self.config.end = Some(0);
}} else {{
self.config.start = Some(-(n as i64));
}}
RangeBuilder {{ config: self.config, _marker: std::marker::PhantomData }}
}}
/// Skip the first n items. Chain with `take(n)` to get a range.
pub fn skip(mut self, n: usize) -> SkippedBuilder<T> {{
self.config.start = Some(n as i64);
SkippedBuilder {{ config: self.config, _marker: std::marker::PhantomData }}
}}
/// Fetch all data as parsed JSON.
pub fn fetch(self) -> Result<MetricData<T>> {{
self.config.get_json(None)
}}
/// Fetch all data as CSV string.
pub fn fetch_csv(self) -> Result<String> {{
self.config.get_text(Some("csv"))
}}
/// Get the base endpoint path.
pub fn path(&self) -> String {{
self.config.path()
}}
}}
/// Builder for single item access.
pub struct SingleItemBuilder<T> {{
config: EndpointConfig,
_marker: std::marker::PhantomData<T>,
}}
impl<T: DeserializeOwned> SingleItemBuilder<T> {{
/// Fetch the single item.
pub fn fetch(self) -> Result<MetricData<T>> {{
self.config.get_json(None)
}}
/// Fetch the single item as CSV.
pub fn fetch_csv(self) -> Result<String> {{
self.config.get_text(Some("csv"))
}}
}}
/// Builder after calling `skip(n)`. Chain with `take(n)` to specify count.
pub struct SkippedBuilder<T> {{
config: EndpointConfig,
_marker: std::marker::PhantomData<T>,
}}
impl<T: DeserializeOwned> SkippedBuilder<T> {{
/// Take n items after the skipped position.
pub fn take(mut self, n: usize) -> RangeBuilder<T> {{
let start = self.config.start.unwrap_or(0);
self.config.end = Some(start + n as i64);
RangeBuilder {{ config: self.config, _marker: std::marker::PhantomData }}
}}
/// Fetch from the skipped position to the end.
pub fn fetch(self) -> Result<MetricData<T>> {{
self.config.get_json(None)
}}
/// Fetch from the skipped position to the end as CSV.
pub fn fetch_csv(self) -> Result<String> {{
self.config.get_text(Some("csv"))
}}
}}
/// Builder with range fully specified.
pub struct RangeBuilder<T> {{
config: EndpointConfig,
_marker: std::marker::PhantomData<T>,
}}
impl<T: DeserializeOwned> RangeBuilder<T> {{
/// Fetch the range as parsed JSON.
pub fn fetch(self) -> Result<MetricData<T>> {{
self.config.get_json(None)
}}
/// Fetch the range as CSV string.
pub fn fetch_csv(self) -> Result<String> {{
self.config.get_text(Some("csv"))
}}
}}
"#
)
.unwrap();
}
/// Generate index accessor structs.
pub fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) {
if patterns.is_empty() {
return;
}
// Generate static index arrays
writeln!(output, "// Static index arrays").unwrap();
for (i, pattern) in patterns.iter().enumerate() {
write!(output, "const _I{}: &[Index] = &[", i + 1).unwrap();
for (j, index) in pattern.indexes.iter().enumerate() {
if j > 0 {
write!(output, ", ").unwrap();
}
write!(output, "Index::{}", index).unwrap();
}
writeln!(output, "];").unwrap();
}
writeln!(output).unwrap();
// Generate helper function
writeln!(
output,
r#"#[inline]
fn _ep<T: DeserializeOwned>(c: &Arc<BrkClientBase>, n: &Arc<str>, i: Index) -> MetricEndpointBuilder<T> {{
MetricEndpointBuilder::new(c.clone(), n.clone(), i)
}}
"#
)
.unwrap();
// Generate index accessor structs
writeln!(output, "// Index accessor structs\n").unwrap();
for (i, pattern) in patterns.iter().enumerate() {
let by_name = format!("{}By", pattern.name);
let idx_const = format!("_I{}", i + 1);
// Generate the "By" struct
writeln!(output, "pub struct {}<T> {{ client: Arc<BrkClientBase>, name: Arc<str>, _marker: std::marker::PhantomData<T> }}", by_name).unwrap();
writeln!(output, "impl<T: DeserializeOwned> {}<T> {{", by_name).unwrap();
for index in &pattern.indexes {
let method_name = index_to_field_name(index);
writeln!(
output,
" pub fn {}(&self) -> MetricEndpointBuilder<T> {{ _ep(&self.client, &self.name, Index::{}) }}",
method_name, index
)
.unwrap();
}
writeln!(output, "}}\n").unwrap();
// Generate the main accessor struct
writeln!(
output,
"pub struct {}<T> {{ name: Arc<str>, pub by: {}<T> }}",
pattern.name, by_name
)
.unwrap();
writeln!(output, "impl<T: DeserializeOwned> {}<T> {{", pattern.name).unwrap();
writeln!(
output,
" pub fn new(client: Arc<BrkClientBase>, name: String) -> Self {{ let name: Arc<str> = name.into(); Self {{ name: name.clone(), by: {} {{ client, name, _marker: std::marker::PhantomData }} }} }}",
by_name
)
.unwrap();
writeln!(output, " pub fn name(&self) -> &str {{ &self.name }}").unwrap();
writeln!(output, "}}\n").unwrap();
// Implement AnyMetricPattern trait
writeln!(
output,
"impl<T> AnyMetricPattern for {}<T> {{ fn name(&self) -> &str {{ &self.name }} fn indexes(&self) -> &'static [Index] {{ {} }} }}",
pattern.name, idx_const
)
.unwrap();
// Implement MetricPattern<T> trait
writeln!(
output,
"impl<T: DeserializeOwned> MetricPattern<T> for {}<T> {{ fn get(&self, index: Index) -> Option<MetricEndpointBuilder<T>> {{ {}.contains(&index).then(|| _ep(&self.by.client, &self.by.name, index)) }} }}\n",
pattern.name, idx_const
)
.unwrap();
}
}
/// Generate structural pattern structs.
pub fn generate_pattern_structs(
output: &mut String,
patterns: &[StructuralPattern],
metadata: &ClientMetadata,
) {
if patterns.is_empty() {
return;
}
writeln!(output, "// Reusable pattern structs\n").unwrap();
for pattern in patterns {
let generic_params = if pattern.is_generic { "<T>" } else { "" };
// Generate struct definition
writeln!(output, "/// Pattern struct for repeated tree structure.").unwrap();
writeln!(output, "pub struct {}{} {{", pattern.name, generic_params).unwrap();
for field in &pattern.fields {
let field_name = to_snake_case(&field.name);
let type_annotation = metadata.field_type_annotation(
field,
pattern.is_generic,
None,
GenericSyntax::RUST,
);
writeln!(output, " pub {}: {},", field_name, type_annotation).unwrap();
}
writeln!(output, "}}\n").unwrap();
// Generate impl block with constructor for ALL patterns
let impl_generic = if pattern.is_generic {
"<T: DeserializeOwned>"
} else {
""
};
writeln!(
output,
"impl{} {}{} {{",
impl_generic, pattern.name, generic_params
)
.unwrap();
writeln!(
output,
" /// Create a new pattern node with accumulated metric name."
)
.unwrap();
writeln!(
output,
" pub fn new(client: Arc<BrkClientBase>, acc: String) -> Self {{"
)
.unwrap();
writeln!(output, " Self {{").unwrap();
let syntax = RustSyntax;
for field in &pattern.fields {
generate_parameterized_field(output, &syntax, field, pattern, metadata, " ");
}
writeln!(output, " }}").unwrap();
writeln!(output, " }}").unwrap();
writeln!(output, "}}\n").unwrap();
}
}
@@ -0,0 +1,45 @@
//! Rust client generation.
//!
//! This module generates a Rust client with full type safety for the BRK API.
pub mod api;
pub mod client;
pub mod tree;
mod types;
use std::{fmt::Write, io, path::Path};
use super::write_if_changed;
use crate::{ClientMetadata, Endpoint};
/// Generate Rust client from metadata and OpenAPI endpoints.
///
/// `output_path` is the full path to the output file (e.g., "crates/brk_client/src/lib.rs").
pub fn generate_rust_client(
metadata: &ClientMetadata,
endpoints: &[Endpoint],
output_path: &Path,
) -> io::Result<()> {
let mut output = String::new();
writeln!(output, "// Auto-generated BRK Rust client").unwrap();
writeln!(output, "// Do not edit manually\n").unwrap();
writeln!(output, "#![allow(non_camel_case_types)]").unwrap();
writeln!(output, "#![allow(dead_code)]").unwrap();
writeln!(output, "#![allow(unused_variables)]").unwrap();
writeln!(output, "#![allow(clippy::useless_format)]").unwrap();
writeln!(output, "#![allow(clippy::unnecessary_to_owned)]\n").unwrap();
client::generate_imports(&mut output);
client::generate_base_client(&mut output);
client::generate_metric_pattern_trait(&mut output);
client::generate_endpoint(&mut output);
client::generate_index_accessors(&mut output, &metadata.index_set_patterns);
client::generate_pattern_structs(&mut output, &metadata.structural_patterns, metadata);
tree::generate_tree(&mut output, &metadata.catalog, metadata);
api::generate_main_client(&mut output, endpoints);
write_if_changed(output_path, &output)?;
Ok(())
}
@@ -0,0 +1,134 @@
//! Rust tree structure generation.
use std::collections::BTreeSet;
use std::fmt::Write;
use brk_types::TreeNode;
use crate::{
ClientMetadata, GenericSyntax, LanguageSyntax, PatternField, RustSyntax, build_child_path,
generate_leaf_field, generate_tree_node_field, prepare_tree_node, to_snake_case,
};
/// Generate tree structs.
pub fn generate_tree(output: &mut String, catalog: &TreeNode, metadata: &ClientMetadata) {
writeln!(output, "// Metrics tree\n").unwrap();
let pattern_lookup = metadata.pattern_lookup();
let mut generated = BTreeSet::new();
generate_tree_node(
output,
"MetricsTree",
"",
catalog,
&pattern_lookup,
metadata,
&mut generated,
);
}
fn generate_tree_node(
output: &mut String,
name: &str,
path: &str,
node: &TreeNode,
pattern_lookup: &std::collections::BTreeMap<Vec<PatternField>, String>,
metadata: &ClientMetadata,
generated: &mut BTreeSet<String>,
) {
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
return;
};
// Generate struct definition
writeln!(output, "/// Metrics tree node.").unwrap();
writeln!(output, "pub struct {} {{", name).unwrap();
for child in &ctx.children {
let field_name = to_snake_case(child.name);
let type_annotation = if child.should_inline {
child.inline_type_name.clone()
} else {
metadata.resolve_tree_field_type(
&child.field,
child.child_fields.as_deref(),
name,
child.name,
GenericSyntax::RUST,
)
};
writeln!(output, " pub {}: {},", field_name, type_annotation).unwrap();
}
writeln!(output, "}}\n").unwrap();
// Generate impl block
writeln!(output, "impl {} {{", name).unwrap();
writeln!(
output,
" pub fn new(client: Arc<BrkClientBase>, base_path: String) -> Self {{"
)
.unwrap();
writeln!(output, " Self {{").unwrap();
let syntax = RustSyntax;
for child in &ctx.children {
let field_name = to_snake_case(child.name);
if child.is_leaf {
if let TreeNode::Leaf(leaf) = child.node {
generate_leaf_field(
output,
&syntax,
"client.clone()",
child.name,
leaf,
metadata,
" ",
);
}
} else if child.should_inline {
// Inline struct type - only for nodes that don't match any pattern
let path_expr = syntax.path_expr("base_path", &format!("_{}", child.name));
writeln!(
output,
" {}: {}::new(client.clone(), {}),",
field_name, child.inline_type_name, path_expr
)
.unwrap();
} else {
// Pattern type - use ::new() constructor
// All patterns have ::new(), parameterizable ones use detected mode,
// non-parameterizable ones use field name fallback
generate_tree_node_field(
output,
&syntax,
&child.field,
metadata,
" ",
child.name,
Some(&child.base_result.base),
);
}
}
writeln!(output, " }}").unwrap();
writeln!(output, " }}").unwrap();
writeln!(output, "}}\n").unwrap();
// Generate child structs
for child in &ctx.children {
if child.should_inline {
let child_path = build_child_path(path, child.name);
generate_tree_node(
output,
&child.inline_type_name,
&child_path,
child.node,
pattern_lookup,
metadata,
generated,
);
}
}
}
@@ -0,0 +1,17 @@
//! Rust type conversion utilities.
/// Convert JS-style type to Rust type.
pub fn js_type_to_rust(js_type: &str) -> String {
if let Some(inner) = js_type.strip_suffix("[]") {
format!("Vec<{}>", js_type_to_rust(inner))
} else {
match js_type {
"string" => "String".to_string(),
"integer" => "i64".to_string(),
"number" => "f64".to_string(),
"boolean" => "bool".to_string(),
"*" => "serde_json::Value".to_string(),
other => other.to_string(),
}
}
}
+187
View File
@@ -0,0 +1,187 @@
#![allow(clippy::type_complexity)]
use std::{collections::btree_map::Entry, fs::create_dir_all, io, path::PathBuf};
use brk_query::Vecs;
/// Output path configuration for each language client.
///
/// Each path should be the full path to the output file, not just a directory.
/// Parent directories will be created automatically if they don't exist.
///
/// # Example
/// ```ignore
/// let paths = ClientOutputPaths::new()
/// .rust("crates/brk_client/src/lib.rs")
/// .javascript("modules/brk-client/index.js")
/// .python("packages/brk_client/__init__.py");
/// ```
#[derive(Debug, Clone, Default)]
pub struct ClientOutputPaths {
/// Full path to Rust client file (e.g., "crates/brk_client/src/lib.rs")
pub rust: Option<PathBuf>,
/// Full path to JavaScript client file (e.g., "modules/brk-client/index.js")
pub javascript: Option<PathBuf>,
/// Full path to Python client file (e.g., "packages/brk_client/__init__.py")
pub python: Option<PathBuf>,
}
impl ClientOutputPaths {
pub fn new() -> Self {
Self::default()
}
pub fn rust(mut self, path: impl Into<PathBuf>) -> Self {
self.rust = Some(path.into());
self
}
pub fn javascript(mut self, path: impl Into<PathBuf>) -> Self {
self.javascript = Some(path.into());
self
}
pub fn python(mut self, path: impl Into<PathBuf>) -> Self {
self.python = Some(path.into());
self
}
}
mod analysis;
mod backends;
mod generate;
mod generators;
mod openapi;
mod syntax;
mod types;
pub use analysis::*;
pub use backends::*;
pub use generate::*;
pub use generators::*;
pub use openapi::*;
pub use syntax::*;
pub use types::*;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Generate all client libraries from the query vecs and OpenAPI JSON.
///
/// Uses `ClientOutputPaths` to specify the output file path for each language.
/// Only languages with a configured path will be generated.
///
/// # Example
/// ```ignore
/// let paths = ClientOutputPaths::new()
/// .rust("crates/brk_client/src/lib.rs")
/// .javascript("modules/brk-client/index.js")
/// .python("packages/brk_client/__init__.py");
///
/// generate_clients(&vecs, &openapi_json, &paths)?;
/// ```
pub fn generate_clients(
vecs: &Vecs,
openapi_json: &str,
output_paths: &ClientOutputPaths,
) -> io::Result<()> {
let metadata = ClientMetadata::from_vecs(vecs);
// Parse OpenAPI spec
let spec = parse_openapi_json(openapi_json)?;
let endpoints = extract_endpoints(&spec);
let mut schemas = extract_schemas(openapi_json);
// Collect leaf type schemas from the catalog and merge into schemas
collect_leaf_type_schemas(&metadata.catalog, &mut schemas);
// Also collect definitions from all schemas (including OpenAPI schemas)
// We need to do this after collecting leaf schemas so we process everything
let schema_values: Vec<_> = schemas.values().cloned().collect();
for schema in &schema_values {
collect_schema_definitions(schema, &mut schemas);
}
// Generate Rust client (uses real brk_types, no schema conversion needed)
if let Some(rust_path) = &output_paths.rust {
if let Some(parent) = rust_path.parent() {
create_dir_all(parent)?;
}
generate_rust_client(&metadata, &endpoints, rust_path)?;
}
// Generate JavaScript client (needs schemas for type definitions)
if let Some(js_path) = &output_paths.javascript {
if let Some(parent) = js_path.parent() {
create_dir_all(parent)?;
}
generate_javascript_client(&metadata, &endpoints, &schemas, js_path)?;
}
// Generate Python client (needs schemas for type definitions)
if let Some(python_path) = &output_paths.python {
if let Some(parent) = python_path.parent() {
create_dir_all(parent)?;
}
generate_python_client(&metadata, &endpoints, &schemas, python_path)?;
}
Ok(())
}
use brk_types::TreeNode;
use serde_json::Value;
/// Recursively collect leaf type schemas from the tree and add to schemas map.
/// Only adds schemas that aren't already present (OpenAPI schemas take precedence).
/// Collects definitions from schemars-generated schemas (for referenced types).
fn collect_leaf_type_schemas(node: &TreeNode, schemas: &mut TypeSchemas) {
match node {
TreeNode::Leaf(leaf) => {
// Collect definitions from the schema (schemars puts type schemas here)
// This includes the inner types like `Bitcoin` from `Close<Bitcoin>`
collect_schema_definitions(&leaf.schema, schemas);
// Get the type name for this leaf
let type_name = extract_inner_type(leaf.kind());
if let Entry::Vacant(e) = schemas.entry(type_name) {
// Unwrap single-element allOf
let schema = unwrap_allof(&leaf.schema);
// Add the schema if it's usable:
// - Simple type (has "type")
// - Object type with properties (complex types like OHLCCents, EmptyAddressData)
// - Enum type (has "enum" or "oneOf")
// - Or a $ref to another type
let has_type = schema.get("type").is_some();
let has_properties = schema.get("properties").is_some();
let has_enum = schema.get("enum").is_some() || schema.get("oneOf").is_some();
let is_ref = schema.get("$ref").is_some();
if has_type || has_properties || has_enum || is_ref {
e.insert(schema.clone());
}
}
}
TreeNode::Branch(children) => {
for child in children.values() {
collect_leaf_type_schemas(child, schemas);
}
}
}
}
/// Collect type definitions from schemars-generated schema's definitions section.
/// Schemars uses `definitions` or `$defs` to store referenced types.
fn collect_schema_definitions(schema: &Value, schemas: &mut TypeSchemas) {
// Check both JSON Schema draft-07 style ("definitions") and draft 2019-09+ style ("$defs")
for key in ["definitions", "$defs"] {
if let Some(defs) = schema.get(key).and_then(|d| d.as_object()) {
for (name, def_schema) in defs {
if !schemas.contains_key(name) {
schemas.insert(name.clone(), def_schema.clone());
}
}
}
}
}
+340
View File
@@ -0,0 +1,340 @@
use std::{collections::BTreeMap, io};
use crate::ref_to_type_name;
use oas3::Spec;
use oas3::spec::{
ObjectOrReference, ObjectSchema, Operation, ParameterIn, PathItem, Schema, SchemaType,
SchemaTypeSet,
};
use serde_json::Value;
/// Type schema extracted from OpenAPI components
pub type TypeSchemas = BTreeMap<String, Value>;
/// Endpoint information extracted from OpenAPI spec
#[derive(Debug, Clone)]
pub struct Endpoint {
/// HTTP method (GET, POST, etc.)
pub method: String,
/// Path template (e.g., "/blocks/{hash}")
pub path: String,
/// Operation ID (e.g., "getBlockByHash")
pub operation_id: Option<String>,
/// Short summary
pub summary: Option<String>,
/// Detailed description
pub description: Option<String>,
/// Path parameters
pub path_params: Vec<Parameter>,
/// Query parameters
pub query_params: Vec<Parameter>,
/// Response type (simplified)
pub response_type: Option<String>,
/// Whether this endpoint is deprecated
pub deprecated: bool,
/// Whether this endpoint supports CSV format (text/csv content type)
pub supports_csv: bool,
}
impl Endpoint {
/// Returns true if this endpoint should be included in client generation.
/// Only non-deprecated GET endpoints are included.
pub fn should_generate(&self) -> bool {
self.method == "GET" && !self.deprecated
}
/// Returns the operation ID or generates one from the path.
/// The returned string uses the raw case from the spec (typically camelCase).
pub fn operation_name(&self) -> String {
if let Some(op_id) = &self.operation_id {
return op_id.clone();
}
// Generate from path: /api/block/{hash} -> "get_block"
// Skip "api" prefix, convert hyphens to underscores, avoid redundant param names
let mut parts: Vec<String> = Vec::new();
let mut prev_segment = "";
for segment in self.path.split('/').filter(|s| !s.is_empty()) {
if segment == "api" {
continue;
}
if let Some(param) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
// Only add "by_{param}" if the previous segment doesn't already contain the param name
let prev_normalized = prev_segment.replace('-', "_");
if !prev_normalized.ends_with(param) {
parts.push(format!("by_{}", param));
}
} else {
let normalized = segment.replace('-', "_");
parts.push(normalized);
prev_segment = segment;
}
}
format!("get_{}", parts.join("_"))
}
}
/// Parameter information
#[derive(Debug, Clone)]
pub struct Parameter {
pub name: String,
pub required: bool,
pub param_type: String,
pub description: Option<String>,
}
/// Parse OpenAPI spec from JSON string
///
/// Pre-processes the JSON to handle oas3 limitations:
/// - Removes unsupported siblings from `$ref` objects (oas3 only supports `summary` and `description`)
pub fn parse_openapi_json(json: &str) -> io::Result<Spec> {
let mut value: Value =
serde_json::from_str(json).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
// Clean up for oas3 compatibility
clean_for_oas3(&mut value);
let cleaned_json =
serde_json::to_string(&value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
oas3::from_json(&cleaned_json).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
/// Extract type schemas from OpenAPI JSON
pub fn extract_schemas(json: &str) -> TypeSchemas {
let Ok(value) = serde_json::from_str::<Value>(json) else {
return BTreeMap::new();
};
value
.get("components")
.and_then(|c| c.get("schemas"))
.and_then(|s| s.as_object())
.map(|schemas| {
schemas
.iter()
.map(|(name, schema)| (name.clone(), schema.clone()))
.collect()
})
.unwrap_or_default()
}
/// Clean up OpenAPI spec for oas3 compatibility.
/// - Removes unsupported siblings from $ref objects (oas3 only supports summary and description)
/// - Converts boolean schemas to object schemas (oas3 doesn't handle `"schema": true`)
fn clean_for_oas3(value: &mut Value) {
match value {
Value::Object(map) => {
// Handle $ref with unsupported siblings
if map.contains_key("$ref") {
map.retain(|k, _| k == "$ref" || k == "summary" || k == "description");
} else {
// Convert boolean schemas to empty object schemas
if let Some(schema) = map.get_mut("schema")
&& schema.is_boolean()
{
*schema = Value::Object(serde_json::Map::new());
}
for v in map.values_mut() {
clean_for_oas3(v);
}
}
}
Value::Array(arr) => {
for v in arr {
clean_for_oas3(v);
}
}
_ => {}
}
}
/// Extract all endpoints from OpenAPI spec
pub fn extract_endpoints(spec: &Spec) -> Vec<Endpoint> {
let mut endpoints = Vec::new();
let Some(paths) = &spec.paths else {
return endpoints;
};
for (path, path_item) in paths {
for (method, operation) in get_operations(path_item) {
if let Some(endpoint) = extract_endpoint(path, method, operation) {
endpoints.push(endpoint);
}
}
}
endpoints
}
fn get_operations(path_item: &PathItem) -> Vec<(&'static str, &Operation)> {
[
("GET", &path_item.get),
("POST", &path_item.post),
("PUT", &path_item.put),
("DELETE", &path_item.delete),
("PATCH", &path_item.patch),
]
.into_iter()
.filter_map(|(method, op)| op.as_ref().map(|o| (method, o)))
.collect()
}
fn extract_endpoint(path: &str, method: &str, operation: &Operation) -> Option<Endpoint> {
let path_params = extract_path_parameters(path, operation);
let query_params = extract_parameters(operation, ParameterIn::Query);
let response_type = extract_response_type(operation);
let supports_csv = check_csv_support(operation);
Some(Endpoint {
method: method.to_string(),
path: path.to_string(),
operation_id: operation.operation_id.clone(),
summary: operation.summary.clone(),
description: operation.description.clone(),
path_params,
query_params,
response_type,
deprecated: operation.deprecated.unwrap_or(false),
supports_csv,
})
}
/// Check if the endpoint supports CSV format (has text/csv in 200 response content types).
fn check_csv_support(operation: &Operation) -> bool {
let Some(responses) = operation.responses.as_ref() else {
return false;
};
let Some(response) = responses.get("200") else {
return false;
};
match response {
ObjectOrReference::Object(response) => response.content.contains_key("text/csv"),
ObjectOrReference::Ref { .. } => false,
}
}
/// Extract path parameters in the order they appear in the path URL.
fn extract_path_parameters(path: &str, operation: &Operation) -> Vec<Parameter> {
// Extract parameter names from the path in order (e.g., "/api/metric/{metric}/{index}" -> ["metric", "index"])
let path_order: Vec<&str> = path
.split('/')
.filter_map(|segment| segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')))
.collect();
// Get all path parameters from the operation
let params = extract_parameters(operation, ParameterIn::Path);
// Sort by position in the path
let mut sorted_params: Vec<Parameter> = params;
sorted_params.sort_by_key(|p| {
path_order
.iter()
.position(|&name| name == p.name)
.unwrap_or(usize::MAX)
});
sorted_params
}
fn extract_parameters(operation: &Operation, location: ParameterIn) -> Vec<Parameter> {
operation
.parameters
.iter()
.filter_map(|p| match p {
ObjectOrReference::Object(param) if param.location == location => {
let param_type = param
.schema
.as_ref()
.and_then(|s| match s {
ObjectOrReference::Ref { ref_path, .. } => {
ref_to_type_name(ref_path).map(|s| s.to_string())
}
ObjectOrReference::Object(obj_schema) => schema_to_type_name(obj_schema),
})
.unwrap_or_else(|| "string".to_string());
Some(Parameter {
name: param.name.clone(),
required: param.required.unwrap_or(false),
param_type,
description: param.description.clone(),
})
}
_ => None,
})
.collect()
}
fn extract_response_type(operation: &Operation) -> Option<String> {
let responses = operation.responses.as_ref()?;
// Look for 200 OK response
let response = responses.get("200")?;
match response {
ObjectOrReference::Object(response) => {
// Look for JSON content
let content = response.content.get("application/json")?;
match &content.schema {
Some(ObjectOrReference::Ref { ref_path, .. }) => {
// Extract type name from reference like "#/components/schemas/Block"
Some(ref_to_type_name(ref_path)?.to_string())
}
Some(ObjectOrReference::Object(schema)) => schema_to_type_name(schema),
None => None,
}
}
ObjectOrReference::Ref { .. } => None,
}
}
fn schema_type_from_schema(schema: &Schema) -> Option<String> {
match schema {
Schema::Boolean(_) => Some("boolean".to_string()),
Schema::Object(obj_or_ref) => match obj_or_ref.as_ref() {
ObjectOrReference::Object(obj_schema) => schema_to_type_name(obj_schema),
ObjectOrReference::Ref { ref_path, .. } => {
// Return the type name as-is (e.g., "Height", "Address")
// These should have definitions generated from schemas
ref_to_type_name(ref_path).map(|s| s.to_string())
}
},
}
}
fn schema_to_type_name(schema: &ObjectSchema) -> Option<String> {
let schema_type = schema.schema_type.as_ref()?;
match schema_type {
SchemaTypeSet::Single(t) => single_type_to_name(t, schema),
SchemaTypeSet::Multiple(types) => {
// For nullable types like ["integer", "null"], return the non-null type
types
.iter()
.find(|t| !matches!(t, SchemaType::Null))
.and_then(|t| single_type_to_name(t, schema))
.or(Some("*".to_string()))
}
}
}
fn single_type_to_name(t: &SchemaType, schema: &ObjectSchema) -> Option<String> {
match t {
SchemaType::String => Some("string".to_string()),
SchemaType::Number => Some("number".to_string()),
SchemaType::Integer => Some("number".to_string()),
SchemaType::Boolean => Some("boolean".to_string()),
SchemaType::Array => {
let inner = match &schema.items {
Some(boxed_schema) => schema_type_from_schema(boxed_schema),
None => Some("*".to_string()),
};
inner.map(|t| format!("{}[]", t))
}
SchemaType::Object => Some("Object".to_string()),
SchemaType::Null => Some("null".to_string()),
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Language-specific syntax traits for code generation.
//!
//! This module defines the `LanguageSyntax` trait that abstracts over
//! language-specific code generation patterns, allowing shared generation
//! logic to work across Python, JavaScript, and Rust backends.
use crate::GenericSyntax;
/// Language-specific syntax for code generation.
///
/// Implementations of this trait provide the language-specific formatting
/// for generated client code. This allows the core generation logic to be
/// written once and reused across all supported languages.
pub trait LanguageSyntax {
/// Convert a field name to the language's naming convention.
///
/// - Python/Rust: `snake_case`
/// - JavaScript: `camelCase`
fn field_name(&self, name: &str) -> String;
/// Format an interpolated path expression.
///
/// # Arguments
/// * `base_var` - The variable name to interpolate (e.g., "acc", "base_path")
/// * `suffix` - The suffix to append (e.g., "_field_name")
///
/// # Returns
/// - Python: `f'{acc}_suffix'`
/// - JavaScript: `` `${acc}_suffix` ``
/// - Rust: `format!("{acc}_suffix")`
fn path_expr(&self, base_var: &str, suffix: &str) -> String;
/// Format a suffix mode expression: `_m(acc, relative)`.
///
/// Suffix mode appends the relative name to the accumulator.
/// - If relative is empty, returns just acc (identity)
/// - Otherwise: `{acc}_{relative}` or `{relative}` if acc is empty
///
/// # Arguments
/// * `acc_var` - The accumulator variable name (e.g., "acc")
/// * `relative` - The relative name to append (e.g., "max_cost_basis")
fn suffix_expr(&self, acc_var: &str, relative: &str) -> String;
/// Format a prefix mode expression: `_p(prefix, acc)`.
///
/// Prefix mode prepends the prefix to the accumulator.
/// - If prefix is empty, returns just acc (identity)
/// - Otherwise: `{prefix}{acc}` (prefix includes trailing underscore)
///
/// # Arguments
/// * `prefix` - The prefix to prepend (e.g., "cumulative_")
/// * `acc_var` - The accumulator variable name (e.g., "acc")
fn prefix_expr(&self, prefix: &str, acc_var: &str) -> String;
/// Generate a constructor call for patterns and accessors.
///
/// - Python: `TypeName(client, path)`
/// - JavaScript: `createTypeName(client, path)`
/// - Rust: `TypeName::new(client.clone(), path)`
fn constructor(&self, type_name: &str, path_expr: &str) -> String;
/// Generate a field initialization line.
///
/// # Arguments
/// * `indent` - The indentation string
/// * `name` - The field name (already converted to language convention)
/// * `type_ann` - The type annotation (may be ignored by some languages)
/// * `value` - The initialization value/expression
///
/// # Returns
/// - Python: `{indent}self.{name}: {type_ann} = {value}`
/// - JavaScript: `{indent}{name}: {value},`
/// - Rust: `{indent}{name}: {value},`
fn field_init(&self, indent: &str, name: &str, type_ann: &str, value: &str) -> String;
/// Get the generic type syntax for this language.
///
/// - Python: `[T]` with default `Any`
/// - JavaScript: `<T>` with default `unknown`
/// - Rust: `<T>` with default `_`
fn generic_syntax(&self) -> GenericSyntax;
/// Format a string literal.
///
/// - Python/JavaScript: `'value'` (single quotes)
/// - Rust: `"value"` (double quotes)
fn string_literal(&self, value: &str) -> String;
/// Get the constructor name/prefix for a type.
///
/// - Python: `TypeName`
/// - JavaScript: `createTypeName`
/// - Rust: `TypeName::new`
fn constructor_name(&self, type_name: &str) -> String;
}
+90
View File
@@ -0,0 +1,90 @@
use brk_types::Index;
/// Convert a string to PascalCase (e.g., "fee_rate" -> "FeeRate").
pub fn to_pascal_case(s: &str) -> String {
s.replace('-', "_")
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect()
}
/// Convert a string to snake_case, handling Rust keywords.
pub fn to_snake_case(s: &str) -> String {
// Convert to lowercase and replace dashes with underscores
let sanitized = s.to_lowercase().replace('-', "_");
// Prefix with _ if starts with digit
let sanitized = if sanitized.chars().next().is_some_and(|c| c.is_ascii_digit()) {
format!("_{}", sanitized)
} else {
sanitized
};
// Handle Rust keywords
match sanitized.as_str() {
"type" | "const" | "static" | "match" | "if" | "else" | "loop" | "while" | "for"
| "break" | "continue" | "return" | "fn" | "let" | "mut" | "ref" | "self" | "super"
| "mod" | "use" | "pub" | "crate" | "extern" | "impl" | "trait" | "struct" | "enum"
| "where" | "async" | "await" | "dyn" | "move" => format!("r#{}", sanitized),
_ => sanitized,
}
}
/// Convert a string to camelCase (e.g., "fee_rate" -> "feeRate").
pub fn to_camel_case(s: &str) -> String {
let pascal = to_pascal_case(s);
let mut chars = pascal.chars();
let result = match chars.next() {
None => String::new(),
Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
};
// Prefix with _ if starts with digit
if result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
format!("_{}", result)
} else {
result
}
}
/// Convert an Index to a snake_case field name (e.g., DateIndex -> dateindex).
pub fn index_to_field_name(index: &Index) -> String {
to_snake_case(index.serialize_long())
}
/// Generate a child type/struct/class name (e.g., ParentName + child_name -> ParentName_ChildName).
pub fn child_type_name(parent: &str, child: &str) -> String {
format!("{}_{}", parent, to_pascal_case(child))
}
/// Escape Python reserved keywords by appending an underscore.
/// Also prefixes names starting with digits with an underscore.
pub fn escape_python_keyword(name: &str) -> String {
const PYTHON_KEYWORDS: &[&str] = &[
"False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
"continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global",
"if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return",
"try", "while", "with", "yield",
];
// Prefix with underscore if starts with digit
let name = if name.starts_with(|c: char| c.is_ascii_digit()) {
format!("_{}", name)
} else {
name.to_string()
};
// Append underscore if it's a keyword
if PYTHON_KEYWORDS.contains(&name.as_str()) {
format!("{}_", name)
} else {
name
}
}
+199
View File
@@ -0,0 +1,199 @@
//! Client metadata extracted from brk_query.
use std::collections::{BTreeMap, BTreeSet};
use brk_query::Vecs;
use brk_types::{Index, MetricLeafWithSchema};
use super::{GenericSyntax, IndexSetPattern, PatternField, StructuralPattern, extract_inner_type};
use crate::{PatternBaseResult, analysis};
/// Metadata extracted from brk_query for client generation.
#[derive(Debug)]
pub struct ClientMetadata {
/// The catalog tree structure (with schemas in leaves)
pub catalog: brk_types::TreeNode,
/// Structural patterns - tree node shapes that repeat
pub structural_patterns: Vec<StructuralPattern>,
/// Index set patterns - sets of indexes that appear together on metrics
pub index_set_patterns: Vec<IndexSetPattern>,
/// Maps concrete field signatures to pattern names
concrete_to_pattern: BTreeMap<Vec<PatternField>, String>,
/// Maps concrete field signatures to their type parameter (for generic patterns)
concrete_to_type_param: BTreeMap<Vec<PatternField>, String>,
/// Maps tree paths to their computed PatternBaseResult
node_bases: BTreeMap<String, PatternBaseResult>,
}
impl ClientMetadata {
/// Extract metadata from brk_query::Vecs.
pub fn from_vecs(vecs: &Vecs) -> Self {
Self::from_catalog(vecs.catalog().clone())
}
/// Extract metadata from a catalog TreeNode directly.
pub fn from_catalog(catalog: brk_types::TreeNode) -> Self {
let (structural_patterns, concrete_to_pattern, concrete_to_type_param, node_bases) =
analysis::detect_structural_patterns(&catalog);
let index_set_patterns = analysis::detect_index_patterns(&catalog);
ClientMetadata {
catalog,
structural_patterns,
index_set_patterns,
concrete_to_pattern,
concrete_to_type_param,
node_bases,
}
}
/// Find an index set pattern that matches the given indexes.
pub fn find_index_set_pattern(&self, indexes: &BTreeSet<Index>) -> Option<&IndexSetPattern> {
self.index_set_patterns
.iter()
.find(|p| &p.indexes == indexes)
}
/// Check if a type is a structural pattern name.
pub fn is_pattern_type(&self, type_name: &str) -> bool {
self.structural_patterns.iter().any(|p| p.name == type_name)
}
/// Find a pattern by name.
pub fn find_pattern(&self, name: &str) -> Option<&StructuralPattern> {
self.structural_patterns.iter().find(|p| p.name == name)
}
/// Check if a pattern is generic.
pub fn is_pattern_generic(&self, name: &str) -> bool {
self.find_pattern(name).is_some_and(|p| p.is_generic)
}
/// Check if a pattern by name is fully parameterizable.
/// A pattern is parameterizable if it has a mode AND all its branch fields
/// are also parameterizable (or not patterns at all).
pub fn is_parameterizable(&self, name: &str) -> bool {
self.find_pattern(name).is_some_and(|p| {
if !p.is_parameterizable() {
return false;
}
// Check all branch fields have parameterizable types (or are not patterns)
p.fields.iter().all(|f| {
if f.is_branch() {
self.structural_patterns
.iter()
.find(|pat| pat.name == f.rust_type)
.is_none_or(|pat| pat.is_parameterizable())
} else {
true
}
})
})
}
/// Check if child fields match ANY pattern (parameterizable or not).
/// Used for type annotations - we want to reuse pattern types for all patterns.
pub fn matches_pattern(&self, fields: &[PatternField]) -> bool {
self.concrete_to_pattern.contains_key(fields)
|| self.structural_patterns.iter().any(|p| p.fields == fields)
}
/// Find a pattern by its fields.
pub fn find_pattern_by_fields(&self, fields: &[PatternField]) -> Option<&StructuralPattern> {
self.concrete_to_pattern
.get(fields)
.and_then(|name| self.find_pattern(name))
.or_else(|| self.structural_patterns.iter().find(|p| p.fields == fields))
}
/// Resolve the type name for a tree field.
/// If the field matches ANY pattern (parameterizable or not), returns pattern type.
/// Otherwise returns the inline type name (parent_child format).
pub fn resolve_tree_field_type(
&self,
field: &PatternField,
child_fields: Option<&[PatternField]>,
parent_name: &str,
child_name: &str,
syntax: GenericSyntax,
) -> String {
match child_fields {
// Use pattern type for ANY matching pattern (parameterizable or not)
Some(cf) if self.matches_pattern(cf) => {
let generic_value_type = self.get_type_param(cf).map(String::as_str);
self.field_type_annotation(field, false, generic_value_type, syntax)
}
Some(_) => crate::child_type_name(parent_name, child_name),
None => self.field_type_annotation(field, false, None, syntax),
}
}
/// Get the type parameter for a generic pattern given its concrete fields.
pub fn get_type_param(&self, fields: &[PatternField]) -> Option<&String> {
self.concrete_to_type_param.get(fields)
}
/// Build a lookup map from field signatures to pattern names.
pub fn pattern_lookup(&self) -> BTreeMap<Vec<PatternField>, String> {
let mut lookup = self.concrete_to_pattern.clone();
for p in &self.structural_patterns {
lookup.insert(p.fields.clone(), p.name.clone());
}
lookup
}
/// Get the pre-computed PatternBaseResult for a tree path.
pub fn get_node_base(&self, path: &str) -> Option<&PatternBaseResult> {
self.node_bases.get(path)
}
/// Generate type annotation for a field with language-specific syntax.
pub fn field_type_annotation(
&self,
field: &PatternField,
is_generic: bool,
generic_value_type: Option<&str>,
syntax: GenericSyntax,
) -> String {
let value_type = if is_generic && field.rust_type == "T" {
"T".to_string()
} else {
extract_inner_type(&field.rust_type)
};
if self.is_pattern_type(&field.rust_type) {
if self.is_pattern_generic(&field.rust_type) {
let type_param = field
.type_param
.as_deref()
.or(generic_value_type)
.unwrap_or(if is_generic { "T" } else { syntax.default_type });
return syntax.wrap(&field.rust_type, type_param);
}
field.rust_type.clone()
} else if field.is_branch() {
field.rust_type.clone()
} else if let Some(accessor) = self.find_index_set_pattern(&field.indexes) {
syntax.wrap(&accessor.name, &value_type)
} else {
syntax.wrap("MetricNode", &value_type)
}
}
/// Generate type annotation for a leaf node with language-specific syntax.
///
/// This is a simpler version of `field_type_annotation` that works directly
/// with a `MetricLeafWithSchema` node instead of a `PatternField`.
pub fn field_type_annotation_from_leaf(
&self,
leaf: &MetricLeafWithSchema,
syntax: GenericSyntax,
) -> String {
let value_type = leaf.kind().to_string();
if let Some(accessor) = self.find_index_set_pattern(leaf.indexes()) {
syntax.wrap(&accessor.name, &value_type)
} else {
syntax.wrap("MetricNode", &value_type)
}
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Core types for client generation.
mod case;
mod metadata;
mod positions;
mod schema;
mod structs;
pub use case::*;
pub use metadata::*;
pub use positions::*;
pub use schema::*;
pub use structs::*;
/// Language-specific syntax for generic type annotations.
#[derive(Clone, Copy)]
pub struct GenericSyntax {
pub open: char,
pub close: char,
pub default_type: &'static str,
}
impl GenericSyntax {
pub const PYTHON: Self = Self {
open: '[',
close: ']',
default_type: "Any",
};
pub const JAVASCRIPT: Self = Self {
open: '<',
close: '>',
default_type: "unknown",
};
pub const RUST: Self = Self {
open: '<',
close: '>',
default_type: "_",
};
pub fn wrap(&self, name: &str, type_param: &str) -> String {
// Convert the type_param from Rust syntax to target syntax
let converted = self.convert(type_param);
format!("{}{}{}{}", name, self.open, converted, self.close)
}
/// Convert a type string from Rust generic syntax to target language syntax.
///
/// For Python, wrapper newtypes like `Close<Cents>` are flattened to just `Cents`
/// because Python type aliases can't be parameterized. This matches JS behavior.
pub fn convert(&self, type_str: &str) -> String {
// Flatten nested generics to innermost type (e.g., Close<Cents> -> Cents)
// This is needed because wrapper types like Close, Open, High, Low are
// just type aliases in generated code, not actual generic classes.
extract_inner_type_recursive(type_str)
}
}
/// Extract the innermost type from nested generics.
/// E.g., `Close<Cents>` -> `Cents`, `Foo<Bar<Baz>>` -> `Baz`
fn extract_inner_type_recursive(type_str: &str) -> String {
if let Some(start) = type_str.find('<')
&& let Some(end) = type_str.rfind('>')
{
let inner = &type_str[start + 1..end];
return extract_inner_type_recursive(inner);
}
type_str.to_string()
}
+26
View File
@@ -0,0 +1,26 @@
//! Pattern mode and field parts for metric name reconstruction.
//!
//! Patterns are either suffix mode or prefix mode:
//! - Suffix mode: `_m(acc, relative)` → `acc_relative` or just `relative` if acc empty
//! - Prefix mode: `_p(prefix, acc)` → `prefix_acc` or just `acc` if prefix empty
use std::collections::BTreeMap;
/// How a pattern constructs metric names from the accumulator.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PatternMode {
/// Fields append their relative name to acc.
/// Formula: `_m(acc, relative)` → `{acc}_{relative}` or `{relative}` if acc empty
/// Example: `_m("lth", "max_cost_basis")` → `"lth_max_cost_basis"`
Suffix {
/// Maps field name to its relative name (full metric name when acc = "")
relatives: BTreeMap<String, String>,
},
/// Fields prepend their prefix to acc.
/// Formula: `_p(prefix, acc)` → `{prefix}_{acc}` or `{acc}` if prefix empty
/// Example: `_p("cumulative", "lth_realized_loss")` → `"cumulative_lth_realized_loss"`
Prefix {
/// Maps field name to its prefix (empty string for identity)
prefixes: BTreeMap<String, String>,
},
}
+43
View File
@@ -0,0 +1,43 @@
use serde_json::Value;
/// Unwrap allOf with a single element, returning the inner schema.
/// Schemars uses allOf for composition, but often with just one $ref.
pub fn unwrap_allof(schema: &Value) -> &Value {
if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array())
&& all_of.len() == 1
{
return &all_of[0];
}
schema
}
/// Extract inner type from a wrapper generic like `Close<Dollars>` -> `Dollars`.
/// Also handles malformed types like `Dollars>` (from vecdb's short_type_name).
pub fn extract_inner_type(type_str: &str) -> String {
// Handle proper generic wrappers like `Close<Dollars>` -> `Dollars`
if let Some(start) = type_str.find('<')
&& let Some(end) = type_str.rfind('>')
&& start < end
{
return type_str[start + 1..end].to_string();
}
// Handle malformed types like `Dollars>` (trailing > without <)
if type_str.ends_with('>') && !type_str.contains('<') {
return type_str.trim_end_matches('>').to_string();
}
type_str.to_string()
}
/// Extract type name from a JSON Schema $ref path.
/// E.g., "#/definitions/MyType" -> "MyType", "#/$defs/Foo" -> "Foo"
pub fn ref_to_type_name(ref_path: &str) -> Option<&str> {
ref_path.rsplit('/').next()
}
/// Get union variants from anyOf or oneOf schema.
pub fn get_union_variants(schema: &Value) -> Option<&Vec<Value>> {
schema
.get("anyOf")
.or_else(|| schema.get("oneOf"))
.and_then(|v| v.as_array())
}
+123
View File
@@ -0,0 +1,123 @@
//! Structural pattern and field types.
use std::collections::{BTreeMap, BTreeSet};
use brk_types::Index;
use super::PatternMode;
/// A pattern of indexes that appear together on multiple metrics.
#[derive(Debug, Clone)]
pub struct IndexSetPattern {
/// Pattern name (e.g., "DateHeightIndexes")
pub name: String,
/// The set of indexes
pub indexes: BTreeSet<Index>,
}
/// A structural pattern - a branch structure that appears multiple times.
#[derive(Debug, Clone)]
pub struct StructuralPattern {
/// Pattern name
pub name: String,
/// Ordered list of child fields
pub fields: Vec<PatternField>,
/// How fields construct metric names from acc (None = not parameterizable)
pub mode: Option<PatternMode>,
/// If true, all leaf fields use a type parameter T
pub is_generic: bool,
}
impl StructuralPattern {
/// Returns true if this pattern can be parameterized with an accumulator.
pub fn is_parameterizable(&self) -> bool {
self.mode.is_some()
}
/// Get the field part (relative name or prefix) for a given field.
pub fn get_field_part(&self, field_name: &str) -> Option<&str> {
match &self.mode {
Some(PatternMode::Suffix { relatives }) => {
relatives.get(field_name).map(|s| s.as_str())
}
Some(PatternMode::Prefix { prefixes }) => prefixes.get(field_name).map(|s| s.as_str()),
None => None,
}
}
/// Returns true if this pattern is in suffix mode.
pub fn is_suffix_mode(&self) -> bool {
matches!(&self.mode, Some(PatternMode::Suffix { .. }))
}
/// Check if the given instance field parts match this pattern's field parts.
/// Returns true if all field parts in the pattern match the instance's field parts.
pub fn field_parts_match(&self, instance_field_parts: &BTreeMap<String, String>) -> bool {
match &self.mode {
Some(PatternMode::Suffix { relatives }) => {
// For each field in the pattern, check if the instance has the same suffix
relatives.iter().all(|(field_name, pattern_suffix)| {
instance_field_parts
.get(field_name)
.is_some_and(|instance_suffix| instance_suffix == pattern_suffix)
})
}
Some(PatternMode::Prefix { prefixes }) => {
// For each field in the pattern, check if the instance has the same prefix
prefixes.iter().all(|(field_name, pattern_prefix)| {
instance_field_parts
.get(field_name)
.is_some_and(|instance_prefix| instance_prefix == pattern_prefix)
})
}
None => false, // Non-parameterizable patterns don't use field parts
}
}
}
/// A field in a structural pattern.
#[derive(Debug, Clone, PartialOrd, Ord)]
pub struct PatternField {
/// Field name
pub name: String,
/// Rust type for leaves or pattern name for branches
pub rust_type: String,
/// JSON type from schema
pub json_type: String,
/// For leaves: the set of supported indexes. Empty for branches.
pub indexes: BTreeSet<Index>,
/// For branches referencing generic patterns: the concrete type parameter
pub type_param: Option<String>,
}
impl PatternField {
/// Returns true if this is a leaf field (has indexes).
pub fn is_leaf(&self) -> bool {
!self.indexes.is_empty()
}
/// Returns true if this is a branch field (no indexes).
pub fn is_branch(&self) -> bool {
self.indexes.is_empty()
}
}
impl std::hash::Hash for PatternField {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.name.hash(state);
self.rust_type.hash(state);
self.json_type.hash(state);
self.indexes.hash(state);
}
}
impl PartialEq for PatternField {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.rust_type == other.rust_type
&& self.json_type == other.json_type
&& self.indexes == other.indexes
}
}
impl Eq for PatternField {}
+917
View File
@@ -0,0 +1,917 @@
// //! Tests that verify pattern analysis using the real catalog.
// use std::collections::{BTreeMap, BTreeSet};
// use std::fmt::Write;
// use brk_bindgen::ClientMetadata;
// use brk_types::TreeNode;
// /// Load the catalog from the JSON file.
// fn load_catalog() -> TreeNode {
// let path = concat!(env!("CARGO_MANIFEST_DIR"), "/catalog.json");
// let catalog_json = std::fs::read_to_string(path).expect("Failed to read catalog.json");
// serde_json::from_str(&catalog_json).expect("Failed to parse catalog.json")
// }
// /// Load OpenAPI spec from openapi.json.
// fn load_openapi_json() -> String {
// let path = concat!(env!("CARGO_MANIFEST_DIR"), "/openapi.json");
// std::fs::read_to_string(path).expect("Failed to read openapi.json")
// }
// /// Load metadata from the catalog.
// #[allow(unused)]
// fn load_metadata() -> ClientMetadata {
// ClientMetadata::from_catalog(load_catalog())
// }
// /// Collect all leaf metric names from a tree.
// fn collect_leaf_names(node: &TreeNode, names: &mut BTreeSet<String>) {
// match node {
// TreeNode::Leaf(leaf) => {
// names.insert(leaf.name().to_string());
// }
// TreeNode::Branch(children) => {
// for child in children.values() {
// collect_leaf_names(child, names);
// }
// }
// }
// }
// #[test]
// fn test_catalog_loads() {
// let catalog = load_catalog();
// // Should be a branch with top-level categories
// let TreeNode::Branch(categories) = &catalog else {
// panic!("Expected catalog to be a branch");
// };
// // Check some expected top-level categories exist
// assert!(
// categories.contains_key("addresses"),
// "Missing addresses category"
// );
// assert!(categories.contains_key("blocks"), "Missing blocks category");
// assert!(categories.contains_key("market"), "Missing market category");
// assert!(categories.contains_key("supply"), "Missing supply category");
// println!("Catalog has {} top-level categories", categories.len());
// }
// #[test]
// fn test_all_leaves_have_names() {
// let catalog = load_catalog();
// let mut names = BTreeSet::new();
// collect_leaf_names(&catalog, &mut names);
// println!("Catalog has {} unique metric names", names.len());
// assert!(!names.is_empty(), "Should have at least some metrics");
// // All names should be non-empty
// for name in &names {
// assert!(!name.is_empty(), "Found empty metric name");
// }
// }
// #[test]
// fn test_pattern_detection() {
// let catalog = load_catalog();
// let (patterns, concrete_to_pattern, concrete_to_type_param, _node_bases) =
// brk_bindgen::detect_structural_patterns(&catalog);
// println!("Detected {} structural patterns", patterns.len());
// println!(
// "Concrete to pattern mappings: {}",
// concrete_to_pattern.len()
// );
// println!("Type parameter mappings: {}", concrete_to_type_param.len());
// // Print pattern details
// for pattern in &patterns {
// let mode_str = match &pattern.mode {
// Some(brk_bindgen::PatternMode::Suffix { relatives }) => {
// format!("Suffix({})", relatives.len())
// }
// Some(brk_bindgen::PatternMode::Prefix { prefixes }) => {
// format!("Prefix({})", prefixes.len())
// }
// None => "None".to_string(),
// };
// println!(
// " {} (fields: {}, generic: {}, mode: {})",
// pattern.name,
// pattern.fields.len(),
// pattern.is_generic,
// mode_str
// );
// }
// // Should have detected some patterns
// assert!(!patterns.is_empty(), "Should detect at least some patterns");
// // Check that parameterizable patterns have valid modes
// for pattern in &patterns {
// if pattern.is_parameterizable() {
// let mode = pattern.mode.as_ref().unwrap();
// match mode {
// brk_bindgen::PatternMode::Suffix { relatives } => {
// assert_eq!(
// relatives.len(),
// pattern.fields.len(),
// "Pattern {} should have relative for each field",
// pattern.name
// );
// }
// brk_bindgen::PatternMode::Prefix { prefixes } => {
// assert_eq!(
// prefixes.len(),
// pattern.fields.len(),
// "Pattern {} should have prefix for each field",
// pattern.name
// );
// }
// }
// }
// }
// }
// #[test]
// fn test_cost_basis_pattern() {
// let catalog = load_catalog();
// let (patterns, _, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
// // Find CostBasisPattern2 and inspect it
// let cost_basis = patterns
// .iter()
// .find(|p| p.name == "CostBasisPattern2")
// .expect("CostBasisPattern2 should exist");
// println!("CostBasisPattern2:");
// println!(
// " Fields: {:?}",
// cost_basis
// .fields
// .iter()
// .map(|f| &f.name)
// .collect::<Vec<_>>()
// );
// println!(" Mode: {:?}", cost_basis.mode);
// println!(" Is generic: {}", cost_basis.is_generic);
// // With suffix naming convention (cost_basis_max, cost_basis_min, cost_basis):
// //
// // At root level: common prefix is "cost_basis_" -> suffix mode
// // max -> "max"
// // min -> "min"
// // percentiles -> "" (identity)
// //
// // At lth_ level: common prefix is "lth_cost_basis_" -> suffix mode
// // max -> "max"
// // min -> "min"
// // percentiles -> "" (identity)
// //
// // Both use suffix mode with same relatives, so pattern IS parameterizable!
// assert!(
// cost_basis.is_parameterizable(),
// "CostBasisPattern2 should be parameterizable with consistent suffix mode"
// );
// }
// #[test]
// fn test_realized_pattern3_fields() {
// let catalog = load_catalog();
// let metadata = ClientMetadata::from_catalog(catalog);
// let pattern = metadata
// .find_pattern("RealizedPattern3")
// .expect("RealizedPattern3 should exist");
// println!("RealizedPattern3 fields:");
// for field in &pattern.fields {
// let is_branch = field.is_branch();
// let is_pattern = metadata.find_pattern(&field.rust_type).is_some();
// let is_param = metadata.is_parameterizable(&field.rust_type);
// println!(
// " {} -> {} (branch={}, pattern={}, param={})",
// field.name, field.rust_type, is_branch, is_pattern, is_param
// );
// }
// // Check if RealizedPattern3 is considered parameterizable
// println!(
// "\nRealizedPattern3 is_parameterizable (metadata): {}",
// metadata.is_parameterizable("RealizedPattern3")
// );
// }
// #[test]
// fn test_parameterizable_patterns_have_mode() {
// let catalog = load_catalog();
// let (patterns, _, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
// // All patterns that appear 2+ times should either:
// // 1. Be parameterizable (have a mode)
// // 2. Or have inconsistent instances (mode = None)
// //
// // Patterns with mode = None should be inlined, not generate factories
// let parameterizable: Vec<_> = patterns.iter().filter(|p| p.is_parameterizable()).collect();
// let non_parameterizable: Vec<_> = patterns
// .iter()
// .filter(|p| !p.is_parameterizable())
// .collect();
// println!("\nParameterizable patterns ({}):", parameterizable.len());
// for p in &parameterizable {
// let mode = p.mode.as_ref().unwrap();
// let mode_type = match mode {
// brk_bindgen::PatternMode::Suffix { .. } => "Suffix",
// brk_bindgen::PatternMode::Prefix { .. } => "Prefix",
// };
// println!(" {} ({} fields, {})", p.name, p.fields.len(), mode_type);
// }
// println!(
// "\nNon-parameterizable patterns ({}):",
// non_parameterizable.len()
// );
// for p in &non_parameterizable {
// println!(" {} ({} fields)", p.name, p.fields.len());
// }
// // Verify all parameterizable patterns have valid modes with all fields
// for pattern in &parameterizable {
// let mode = pattern.mode.as_ref().unwrap();
// let field_names: BTreeSet<_> = pattern.fields.iter().map(|f| f.name.clone()).collect();
// match mode {
// brk_bindgen::PatternMode::Suffix { relatives } => {
// let mode_fields: BTreeSet<_> = relatives.keys().cloned().collect();
// assert_eq!(
// field_names, mode_fields,
// "Pattern {} suffix mode should have all fields",
// pattern.name
// );
// }
// brk_bindgen::PatternMode::Prefix { prefixes } => {
// let mode_fields: BTreeSet<_> = prefixes.keys().cloned().collect();
// assert_eq!(
// field_names, mode_fields,
// "Pattern {} prefix mode should have all fields",
// pattern.name
// );
// }
// }
// }
// }
// #[test]
// fn test_fee_rate_pattern_relatives() {
// let catalog = load_catalog();
// let (patterns, _, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
// let fee_rate_pattern = patterns
// .iter()
// .find(|p| p.name == "FeeRatePattern")
// .expect("FeeRatePattern should exist");
// println!("FeeRatePattern mode:");
// if let Some(mode) = &fee_rate_pattern.mode {
// match mode {
// brk_bindgen::PatternMode::Suffix { relatives } => {
// println!(" Suffix mode:");
// for (field, relative) in relatives {
// println!(" {} -> '{}'", field, relative);
// }
// }
// brk_bindgen::PatternMode::Prefix { prefixes } => {
// println!(" Prefix mode:");
// for (field, prefix) in prefixes {
// println!(" {} -> '{}'", field, prefix);
// }
// }
// }
// } else {
// println!(" No mode (not parameterizable)");
// }
// // Check that relatives are correct - should be "average", "max", etc.
// // NOT "tx_weight_average", "tx_weight_max", etc.
// if let Some(brk_bindgen::PatternMode::Suffix { relatives }) = &fee_rate_pattern.mode {
// assert_eq!(
// relatives.get("average"),
// Some(&"average".to_string()),
// "average relative should be 'average', not 'tx_weight_average'"
// );
// }
// }
// #[test]
// fn test_index_patterns() {
// let catalog = load_catalog();
// let index_patterns = brk_bindgen::detect_index_patterns(&catalog);
// // println!("Used indexes: {:?}", used_indexes);
// println!("Index set patterns: {}", index_patterns.len());
// for pattern in &index_patterns {
// println!(" {} -> {:?}", pattern.name, pattern.indexes);
// }
// // Should have detected some index patterns
// assert!(!index_patterns.is_empty(), "Should detect index patterns");
// }
// #[test]
// fn test_generated_rust_output() {
// let catalog = load_catalog();
// let metadata = ClientMetadata::from_catalog(catalog.clone());
// // Collect all metric names from the catalog
// let mut all_metrics = BTreeSet::new();
// collect_leaf_names(&catalog, &mut all_metrics);
// // Generate Rust client output
// let mut rust_output = String::new();
// brk_bindgen::rust::client::generate_imports(&mut rust_output);
// brk_bindgen::rust::client::generate_base_client(&mut rust_output);
// brk_bindgen::rust::client::generate_metric_pattern_trait(&mut rust_output);
// brk_bindgen::rust::client::generate_endpoint(&mut rust_output);
// brk_bindgen::rust::client::generate_index_accessors(
// &mut rust_output,
// &metadata.index_set_patterns,
// );
// brk_bindgen::rust::client::generate_pattern_structs(
// &mut rust_output,
// &metadata.structural_patterns,
// &metadata,
// );
// brk_bindgen::rust::tree::generate_tree(&mut rust_output, &metadata.catalog, &metadata);
// brk_bindgen::rust::api::generate_main_client(&mut rust_output, &[]);
// // Count metrics that appear as direct string literals
// let mut direct_metrics = 0;
// for metric in &all_metrics {
// if rust_output.contains(&format!("\"{}\"", metric)) {
// direct_metrics += 1;
// }
// }
// println!("\nGenerated Rust output stats:");
// println!(" Total metrics in catalog: {}", all_metrics.len());
// println!(" Direct string literals: {}", direct_metrics);
// println!(
// " Via pattern factories: {}",
// all_metrics.len() - direct_metrics
// );
// println!(" Output size: {} bytes", rust_output.len());
// // Write output to test directory (not actual client)
// let output_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/output");
// std::fs::create_dir_all(output_dir).ok();
// let output_path = format!("{}/rust_client.rs", output_dir);
// std::fs::write(&output_path, &rust_output).expect("Failed to write client output");
// println!(" Wrote output to: {}", output_path);
// // Verify the output contains the key components
// assert!(rust_output.contains("fn _m("), "Should define _m helper");
// assert!(
// rust_output.contains("pub struct MetricsTree"),
// "Should have MetricsTree"
// );
// assert!(
// rust_output.contains("impl MetricsTree"),
// "Should have MetricsTree impl"
// );
// // Count parameterizable patterns (these use _m for dynamic metric names)
// // Use metadata.is_parameterizable() for full recursive check
// let parameterizable_count = metadata
// .structural_patterns
// .iter()
// .filter(|p| metadata.is_parameterizable(&p.name))
// .count();
// println!(" Parameterizable patterns: {}", parameterizable_count);
// // Verify all pattern structs are generated (parameterizable and non)
// for pattern in &metadata.structural_patterns {
// assert!(
// rust_output.contains(&format!("pub struct {}", pattern.name)),
// "Missing pattern struct: {}",
// pattern.name
// );
// }
// println!("\nGenerated Rust client is complete!");
// }
// #[test]
// fn test_generated_javascript_output() {
// let catalog = load_catalog();
// let metadata = ClientMetadata::from_catalog(catalog.clone());
// // Collect all metric names from the catalog
// let mut all_metrics = BTreeSet::new();
// collect_leaf_names(&catalog, &mut all_metrics);
// // Load schemas from OpenAPI spec only (catalog schemas require runtime data)
// let openapi_json = load_openapi_json();
// let schemas = brk_bindgen::extract_schemas(&openapi_json);
// // Generate JavaScript client output
// let mut js_output = String::new();
// writeln!(js_output, "// Auto-generated BRK JavaScript client").unwrap();
// writeln!(js_output, "// Do not edit manually\n").unwrap();
// brk_bindgen::javascript::types::generate_type_definitions(&mut js_output, &schemas);
// brk_bindgen::javascript::client::generate_base_client(&mut js_output);
// brk_bindgen::javascript::client::generate_index_accessors(
// &mut js_output,
// &metadata.index_set_patterns,
// );
// brk_bindgen::javascript::client::generate_structural_patterns(
// &mut js_output,
// &metadata.structural_patterns,
// &metadata,
// );
// brk_bindgen::javascript::tree::generate_tree_typedefs(
// &mut js_output,
// &metadata.catalog,
// &metadata,
// );
// brk_bindgen::javascript::tree::generate_main_client(
// &mut js_output,
// &metadata.catalog,
// &metadata,
// &[],
// );
// // Count metrics that appear as direct string literals
// let mut direct_metrics = 0;
// for metric in &all_metrics {
// if js_output.contains(&format!("'{}'", metric))
// || js_output.contains(&format!("\"{}\"", metric))
// {
// direct_metrics += 1;
// }
// }
// println!("\nGenerated JavaScript output stats:");
// println!(" Total metrics in catalog: {}", all_metrics.len());
// println!(" Direct string literals: {}", direct_metrics);
// println!(
// " Via pattern factories: {}",
// all_metrics.len() - direct_metrics
// );
// println!(" Output size: {} bytes", js_output.len());
// println!(" Output lines: {}", js_output.lines().count());
// // Write output to test directory (not actual client)
// let output_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/output");
// std::fs::create_dir_all(output_dir).ok();
// let output_path = format!("{}/js_client.js", output_dir);
// std::fs::write(&output_path, &js_output).expect("Failed to write JS client output");
// println!(" Wrote output to: {}", output_path);
// // Verify the output contains key components
// assert!(js_output.contains("const _m ="), "Should define _m helper");
// assert!(js_output.contains("const _p ="), "Should define _p helper");
// assert!(
// js_output.contains("@typedef {Object} MetricsTree"),
// "Should have MetricsTree typedef"
// );
// assert!(
// js_output.contains("class BrkClient"),
// "Should have BrkClient class"
// );
// // Verify all pattern factories are generated
// for pattern in &metadata.structural_patterns {
// assert!(
// js_output.contains(&format!("function create{}(", pattern.name)),
// "Missing pattern factory: {}",
// pattern.name
// );
// }
// println!("\nGenerated JavaScript client is complete!");
// }
// #[test]
// fn test_generated_python_output() {
// let catalog = load_catalog();
// let metadata = ClientMetadata::from_catalog(catalog.clone());
// // Collect all metric names from the catalog
// let mut all_metrics = BTreeSet::new();
// collect_leaf_names(&catalog, &mut all_metrics);
// // Load schemas from OpenAPI spec only (catalog schemas require runtime data)
// let openapi_json = load_openapi_json();
// let schemas = brk_bindgen::extract_schemas(&openapi_json);
// // Generate Python client output
// let mut py_output = String::new();
// writeln!(py_output, "# Auto-generated BRK Python client").unwrap();
// writeln!(py_output, "# Do not edit manually\n").unwrap();
// writeln!(py_output, "from typing import TypeVar, Generic, Any, Optional, List, Literal, TypedDict, Union, Protocol, overload, Iterator, Tuple, TYPE_CHECKING").unwrap();
// writeln!(py_output, "\nif TYPE_CHECKING:").unwrap();
// writeln!(py_output, " import pandas as pd # type: ignore[import-not-found]").unwrap();
// writeln!(py_output, " import polars as pl # type: ignore[import-not-found]").unwrap();
// writeln!(
// py_output,
// "from http.client import HTTPSConnection, HTTPConnection"
// )
// .unwrap();
// writeln!(py_output, "from urllib.parse import urlparse").unwrap();
// writeln!(py_output, "from datetime import date, timedelta").unwrap();
// writeln!(py_output, "from dataclasses import dataclass").unwrap();
// writeln!(py_output, "import json\n").unwrap();
// writeln!(py_output, "T = TypeVar('T')\n").unwrap();
// brk_bindgen::python::types::generate_type_definitions(&mut py_output, &schemas);
// brk_bindgen::python::client::generate_base_client(&mut py_output);
// brk_bindgen::python::client::generate_endpoint_class(&mut py_output);
// brk_bindgen::python::client::generate_index_accessors(
// &mut py_output,
// &metadata.index_set_patterns,
// );
// brk_bindgen::python::client::generate_structural_patterns(
// &mut py_output,
// &metadata.structural_patterns,
// &metadata,
// );
// brk_bindgen::python::tree::generate_tree_classes(&mut py_output, &metadata.catalog, &metadata);
// brk_bindgen::python::api::generate_main_client(&mut py_output, &[]);
// // Count metrics that appear as direct string literals
// let mut direct_metrics = 0;
// for metric in &all_metrics {
// if py_output.contains(&format!("'{}'", metric))
// || py_output.contains(&format!("\"{}\"", metric))
// {
// direct_metrics += 1;
// }
// }
// println!("\nGenerated Python output stats:");
// println!(" Total metrics in catalog: {}", all_metrics.len());
// println!(" Direct string literals: {}", direct_metrics);
// println!(
// " Via pattern factories: {}",
// all_metrics.len() - direct_metrics
// );
// println!(" Output size: {} bytes", py_output.len());
// println!(" Output lines: {}", py_output.lines().count());
// // Write output to test directory (not actual client)
// let output_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/output");
// std::fs::create_dir_all(output_dir).ok();
// let output_path = format!("{}/python_client.py", output_dir);
// std::fs::write(&output_path, &py_output).expect("Failed to write Python client output");
// println!(" Wrote output to: {}", output_path);
// // Verify the output contains key components
// assert!(py_output.contains("def _m("), "Should define _m helper");
// assert!(py_output.contains("def _p("), "Should define _p helper");
// assert!(
// py_output.contains("class MetricsTree:"),
// "Should have MetricsTree class"
// );
// assert!(
// py_output.contains("class BrkClient"),
// "Should have BrkClient class"
// );
// // Verify all pattern classes have constructors
// for pattern in &metadata.structural_patterns {
// assert!(
// py_output.contains(&format!("class {}:", pattern.name))
// || py_output.contains(&format!("class {}(", pattern.name)),
// "Missing pattern class: {}",
// pattern.name
// );
// }
// println!("\nGenerated Python client is complete!");
// }
// #[test]
// fn test_cost_basis_relatives() {
// let catalog = load_catalog();
// // Find cost_basis branches that have 3 direct children (max, min, percentiles)
// fn find_cost_basis_with_percentiles(
// node: &TreeNode,
// path: &str,
// ) -> Vec<(String, Vec<(String, String)>)> {
// let mut results = Vec::new();
// if let TreeNode::Branch(children) = node {
// for (name, child) in children {
// let child_path = if path.is_empty() {
// name.clone()
// } else {
// format!("{}.{}", path, name)
// };
// if name == "cost_basis"
// && let TreeNode::Branch(cb_children) = child
// && cb_children.contains_key("percentiles")
// {
// // Found a cost_basis with percentiles
// let mut metrics = Vec::new();
// for (field_name, field_node) in cb_children {
// match field_node {
// TreeNode::Leaf(leaf) => {
// metrics.push((field_name.clone(), leaf.name().to_string()));
// }
// TreeNode::Branch(pct_children) => {
// // Get first percentile as example
// if let Some((_, TreeNode::Leaf(first))) = pct_children.iter().next()
// {
// metrics.push((
// format!("{}.first", field_name),
// first.name().to_string(),
// ));
// }
// }
// }
// }
// results.push((child_path.clone(), metrics));
// }
// results.extend(find_cost_basis_with_percentiles(child, &child_path));
// }
// }
// results
// }
// let instances = find_cost_basis_with_percentiles(&catalog, "");
// println!("\nCostBasisPattern2 instances (with percentiles):");
// for (path, metrics) in instances.iter().take(10) {
// println!(" {}:", path);
// for (field, metric) in metrics {
// println!(" {} -> {}", field, metric);
// }
// }
// // Now compute what relatives the pattern detection would see
// // The key is: percentiles returns its BASE (common prefix of pct05, pct10, etc.)
// // not the individual percentile metrics
// use brk_bindgen::find_common_prefix;
// println!("\nComputing relatives (simulating branch base returns):");
// for (path, metrics) in instances.iter().take(5) {
// println!(" Instance: {}", path);
// // For leaves (max, min), the base is the metric name
// // For branches (percentiles), the base is the common prefix of its children
// let mut child_bases: std::collections::BTreeMap<String, String> =
// std::collections::BTreeMap::new();
// for (field, metric) in metrics {
// if field.starts_with("percentiles.") {
// // This is a percentile metric - compute what the percentiles branch would return
// // The base is the metric name with the pct suffix stripped
// let base = metric
// .strip_suffix("_pct05")
// .or_else(|| metric.strip_suffix("_pct10"))
// .unwrap_or(metric)
// .to_string();
// child_bases.insert("percentiles".to_string(), base);
// } else {
// child_bases.insert(field.clone(), metric.clone());
// }
// }
// let bases: Vec<&str> = child_bases.values().map(|s| s.as_str()).collect();
// println!(" Child bases:");
// for (field, base) in &child_bases {
// println!(" {} -> {}", field, base);
// }
// if let Some(prefix) = find_common_prefix(&bases) {
// println!(" Common prefix: '{}'", prefix);
// for (field, base) in &child_bases {
// let relative = base.strip_prefix(&prefix).unwrap_or(base);
// println!(" {} -> relative '{}'", field, relative);
// }
// } else {
// println!(" No common prefix found!");
// }
// }
// }
// #[test]
// fn test_debug_cost_basis_pattern2_mode() {
// // Debug why CostBasisPattern2 has mode=None
// let catalog = load_catalog();
// let metadata = brk_bindgen::ClientMetadata::from_catalog(catalog.clone());
// let pattern_lookup = metadata.pattern_lookup();
// let pattern = metadata
// .find_pattern("CostBasisPattern2")
// .expect("CostBasisPattern2 should exist");
// println!("\nCostBasisPattern2 fields:");
// for field in &pattern.fields {
// println!(" {} (type: {})", field.name, field.rust_type);
// }
// println!("Mode: {:?}", pattern.mode);
// // Now debug the instance collection
// #[derive(Debug, Clone)]
// struct DebugInstanceAnalysis {
// base: String,
// field_parts: std::collections::BTreeMap<String, String>,
// is_suffix_mode: bool,
// }
// fn collect_debug(
// node: &TreeNode,
// pattern_lookup: &std::collections::BTreeMap<Vec<brk_bindgen::PatternField>, String>,
// all_analyses: &mut std::collections::BTreeMap<String, Vec<DebugInstanceAnalysis>>,
// ) -> Option<String> {
// match node {
// TreeNode::Leaf(leaf) => Some(leaf.name().to_string()),
// TreeNode::Branch(children) => {
// let mut child_bases: std::collections::BTreeMap<String, String> =
// std::collections::BTreeMap::new();
// for (field_name, child_node) in children {
// if let Some(base) = collect_debug(child_node, pattern_lookup, all_analyses) {
// child_bases.insert(field_name.clone(), base);
// }
// }
// if child_bases.is_empty() {
// return None;
// }
// // Analyze this instance
// let bases: Vec<&str> = child_bases.values().map(|s| s.as_str()).collect();
// let (base, field_parts, is_suffix_mode) =
// if let Some(common_prefix) = brk_bindgen::find_common_prefix(&bases) {
// let base = common_prefix.trim_end_matches('_').to_string();
// let mut parts = std::collections::BTreeMap::new();
// for (field_name, child_base) in &child_bases {
// let relative = if *child_base == base {
// String::new()
// } else {
// child_base
// .strip_prefix(&common_prefix)
// .unwrap_or(child_base)
// .to_string()
// };
// parts.insert(field_name.clone(), relative);
// }
// (base, parts, true)
// } else {
// let base = child_bases.values().next().cloned().unwrap_or_default();
// let parts = child_bases
// .iter()
// .map(|(k, v)| (k.clone(), v.clone()))
// .collect();
// (base, parts, true)
// };
// let analysis = DebugInstanceAnalysis {
// base: base.clone(),
// field_parts,
// is_suffix_mode,
// };
// // Get the pattern name for this node
// let fields = brk_bindgen::get_node_fields(children, pattern_lookup);
// if let Some(pattern_name) = pattern_lookup.get(&fields) {
// all_analyses
// .entry(pattern_name.clone())
// .or_default()
// .push(analysis);
// }
// Some(base)
// }
// }
// }
// let mut all_analyses: BTreeMap<String, Vec<DebugInstanceAnalysis>> = BTreeMap::new();
// collect_debug(&catalog, &pattern_lookup, &mut all_analyses);
// if let Some(analyses) = all_analyses.get("CostBasisPattern2") {
// println!(
// "\nCollected {} instances of CostBasisPattern2:",
// analyses.len()
// );
// for (i, a) in analyses.iter().enumerate() {
// println!(" Instance {}:", i);
// println!(" base: {}", a.base);
// println!(" is_suffix: {}", a.is_suffix_mode);
// println!(" field_parts:");
// for (f, p) in &a.field_parts {
// println!(" {} -> '{}'", f, p);
// }
// }
// // Check consistency
// if analyses.len() >= 2 {
// let first = &analyses[0];
// for (i, a) in analyses.iter().enumerate().skip(1) {
// if a.is_suffix_mode != first.is_suffix_mode {
// println!(" INCONSISTENT: Instance {} has different mode", i);
// }
// for (field, part) in &a.field_parts {
// if first.field_parts.get(field) != Some(part) {
// println!(
// " INCONSISTENT: Instance {} field '{}' has part '{}' vs '{}'",
// i,
// field,
// part,
// first
// .field_parts
// .get(field)
// .unwrap_or(&"<missing>".to_string())
// );
// }
// }
// }
// }
// } else {
// println!("\nNo instances collected for CostBasisPattern2!");
// }
// }
// #[test]
// fn test_root_cost_basis_prefix() {
// use brk_bindgen::find_common_prefix;
// // Root-level cost_basis has:
// // max -> "max_cost_basis"
// // min -> "min_cost_basis"
// // percentiles -> "cost_basis" (base of pct05, pct10, etc.)
// let bases = vec!["max_cost_basis", "min_cost_basis", "cost_basis"];
// let prefix = find_common_prefix(&bases);
// println!("Root cost_basis prefix: {:?}", prefix);
// // Compare with nested cost_basis
// let nested_bases = vec![
// "utxos_at_least_15y_old_max_cost_basis",
// "utxos_at_least_15y_old_min_cost_basis",
// "utxos_at_least_15y_old_cost_basis",
// ];
// let nested_prefix = find_common_prefix(&nested_bases);
// println!("Nested cost_basis prefix: {:?}", nested_prefix);
// }
// #[test]
// fn test_utxo_cohorts_all_activity_base() {
// // Test that distribution.utxo_cohorts.all.activity uses empty base
// // because its children (coinblocks_destroyed, coindays_destroyed, etc.)
// // have no common prefix or suffix.
// let catalog = load_catalog();
// let metadata = ClientMetadata::from_catalog(catalog.clone());
// // Generate JavaScript output
// let mut js_output = String::new();
// writeln!(js_output, "// Test output").unwrap();
// brk_bindgen::javascript::client::generate_base_client(&mut js_output);
// brk_bindgen::javascript::client::generate_index_accessors(
// &mut js_output,
// &metadata.index_set_patterns,
// );
// brk_bindgen::javascript::client::generate_structural_patterns(
// &mut js_output,
// &metadata.structural_patterns,
// &metadata,
// );
// brk_bindgen::javascript::tree::generate_tree_typedefs(
// &mut js_output,
// &metadata.catalog,
// &metadata,
// );
// brk_bindgen::javascript::tree::generate_main_client(
// &mut js_output,
// &metadata.catalog,
// &metadata,
// &[],
// );
// // The all.activity should use empty base, so metrics don't get duplicated
// // Look for: activity: createActivityPattern2(this, '')
// // NOT: activity: createActivityPattern2(this, 'coinblocks_destroyed')
// assert!(
// !js_output.contains("createActivityPattern2(this, 'coinblocks_destroyed')"),
// "all.activity should NOT use 'coinblocks_destroyed' as base (causes duplication)"
// );
// // Check that it uses empty string as base
// assert!(
// js_output.contains("activity: createActivityPattern2(this, '')"),
// "all.activity should use empty base"
// );
// println!("utxo_cohorts.all.activity base test passed!");
// }
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "brk_bundler"
description = "A thin wrapper around rolldown"
version.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
build = "build.rs"
[dependencies]
log = { workspace = true }
notify = "8.2.0"
# rolldown = { path = "../../../rolldown/crates/rolldown", package = "brk_rolldown" }
rolldown = { version = "0.5.1", package = "brk_rolldown" }
sugar_path = "1.2.1"
tokio = { workspace = true }
-32
View File
@@ -1,32 +0,0 @@
# brk_bundler
JavaScript bundling with watch mode for BRK web interfaces.
## What It Enables
Bundle and minify JavaScript modules using Rolldown, with file watching for development. Handles module copying, source map generation, and cache-busting via hashed filenames.
## Key Features
- **Rolldown integration**: Fast Rust-based bundler with tree-shaking and minification
- **Watch mode**: Rebuilds on file changes with live module syncing
- **Source maps**: Full debugging support in production builds
- **Cache busting**: Hashes main bundle filename, updates HTML references automatically
- **Service worker versioning**: Injects package version into service worker files
## Core API
```rust,ignore
// One-shot build
let dist = bundle(modules_path, websites_path, "src", false).await?;
// Watch mode for development
bundle(modules_path, websites_path, "src", true).await?;
```
## Build Pipeline
1. Copy shared modules to source scripts directory
2. Bundle with Rolldown (minified, with source maps)
3. Update `index.html` with hashed script references
4. Inject version into service worker
-8
View File
@@ -1,8 +0,0 @@
fn main() {
let profile = std::env::var("PROFILE").unwrap_or_default();
if profile == "release" {
println!("cargo:rustc-flag=-C");
println!("cargo:rustc-flag=target-cpu=native");
}
}
-193
View File
@@ -1,193 +0,0 @@
#![doc = include_str!("../README.md")]
use std::{
fs, io,
path::{Path, PathBuf},
sync::Arc,
};
use log::error;
use notify::{EventKind, RecursiveMode, Watcher};
use rolldown::{
Bundler, BundlerOptions, InlineConstConfig, InlineConstMode, InlineConstOption,
OptimizationOption, RawMinifyOptions, SourceMapType,
};
use sugar_path::SugarPath;
use tokio::sync::Mutex;
const VERSION: &str = env!("CARGO_PKG_VERSION");
pub async fn bundle(
modules_path: &Path,
websites_path: &Path,
source_folder: &str,
watch: bool,
) -> io::Result<PathBuf> {
let relative_modules_path = modules_path;
let relative_source_path = websites_path.join(source_folder);
let relative_dist_path = websites_path.join("dist");
let absolute_modules_path = relative_modules_path.absolutize();
let absolute_modules_path_clone = absolute_modules_path.clone();
let absolute_websites_path = websites_path.absolutize();
let absolute_websites_path_clone = absolute_websites_path.clone();
let absolute_source_path = relative_source_path.absolutize();
let absolute_source_index_path = absolute_source_path.join("index.html");
let absolute_source_index_path_clone = absolute_source_index_path.clone();
let absolute_source_scripts_path = absolute_source_path.join("scripts");
let absolute_source_scripts_modules_path = absolute_source_scripts_path.join("modules");
let absolute_source_sw_path = absolute_source_path.join("service-worker.js");
let absolute_source_sw_path_clone = absolute_source_sw_path.clone();
let absolute_dist_path = relative_dist_path.absolutize();
let absolute_dist_scripts_path = absolute_dist_path.join("scripts");
let absolute_dist_scripts_entry_path = absolute_dist_scripts_path.join("entry.js");
let absolute_dist_scripts_entry_path_clone = absolute_dist_scripts_entry_path.clone();
let absolute_dist_index_path = absolute_dist_path.join("index.html");
let absolute_dist_sw_path = absolute_dist_path.join("service-worker.js");
let _ = fs::remove_dir_all(&absolute_dist_path);
let _ = fs::remove_dir_all(&absolute_source_scripts_modules_path);
copy_dir_all(
&absolute_modules_path,
&absolute_source_scripts_modules_path,
)?;
copy_dir_all(&absolute_source_path, &absolute_dist_path)?;
fs::remove_dir_all(&absolute_dist_scripts_path)?;
fs::create_dir(&absolute_dist_scripts_path)?;
// dbg!(BundlerOptions::default());
let mut bundler = Bundler::new(BundlerOptions {
input: Some(vec![format!("./{source_folder}/scripts/entry.js").into()]),
dir: Some("./dist/scripts".to_string()),
cwd: Some(absolute_websites_path),
minify: Some(RawMinifyOptions::Bool(true)),
sourcemap: Some(SourceMapType::File),
// advanced_chunks: Some(AdvancedChunksOptions {
// // min_size: Some(1000.0),
// min_share_count: Some(20),
// // min_module_size: S
// // include_dependencies_recursively: Some(true),
// ..Default::default()
// }),
//
// inline_dynamic_imports
// experimental: Some(ExperimentalOptions {
// strict_execution_order: Some(true),
// ..Default::default()
// }),
optimization: Some(OptimizationOption {
inline_const: Some(InlineConstOption::Config(InlineConstConfig {
mode: Some(InlineConstMode::All),
..Default::default()
})),
// Needs benchmarks
// pife_for_module_wrappers: Some(true),
..Default::default()
}),
..Default::default()
})
.unwrap();
if let Err(error) = bundler.write().await {
error!("{error:?}");
}
let update_dist_index = move || {
let mut contents = fs::read_to_string(&absolute_source_index_path).unwrap();
if let Ok(entry) = fs::read_to_string(&absolute_dist_scripts_entry_path_clone)
&& let Some(start) = entry.find("main")
&& let Some(end) = entry.find(".js")
{
let main_hashed = &entry[start..end];
contents = contents.replace("/scripts/main.js", &format!("/scripts/{main_hashed}.js"));
}
let _ = fs::write(&absolute_dist_index_path, contents);
};
let update_source_sw = move || {
let contents = fs::read_to_string(&absolute_source_sw_path)
.unwrap()
.replace("__VERSION__", &format!("v{VERSION}"));
let _ = fs::write(&absolute_dist_sw_path, contents);
};
update_dist_index();
update_source_sw();
if !watch {
return Ok(relative_dist_path);
}
tokio::spawn(async move {
let mut event_watcher = notify::recommended_watcher(
move |res: Result<notify::Event, notify::Error>| match res {
Ok(event) => match event.kind {
EventKind::Create(_) => event.paths,
EventKind::Modify(_) => event.paths,
_ => vec![],
}
.into_iter()
.for_each(|path| {
let path = path.absolutize();
if path == absolute_dist_scripts_entry_path
|| path == absolute_source_index_path_clone
{
update_dist_index();
} else if path == absolute_source_sw_path_clone {
update_source_sw();
} else if let Ok(suffix) = path.strip_prefix(&absolute_modules_path) {
let source_modules_path = absolute_source_scripts_modules_path.join(suffix);
if path.is_file() {
let _ = fs::create_dir_all(path.parent().unwrap());
let _ = fs::copy(&path, &source_modules_path);
}
} else if let Ok(suffix) = path.strip_prefix(&absolute_source_path)
// scripts are handled by rolldown
&& !path.starts_with(&absolute_source_scripts_path)
{
let dist_path = absolute_dist_path.join(suffix);
if path.is_file() {
let _ = fs::create_dir_all(path.parent().unwrap());
let _ = fs::copy(&path, &dist_path);
}
}
}),
Err(e) => error!("watch error: {e:?}"),
},
)
.unwrap();
event_watcher
.watch(&absolute_websites_path_clone, RecursiveMode::Recursive)
.unwrap();
event_watcher
.watch(&absolute_modules_path_clone, RecursiveMode::Recursive)
.unwrap();
let watcher = rolldown::Watcher::new(vec![Arc::new(Mutex::new(bundler))], None).unwrap();
watcher.start().await;
});
Ok(relative_dist_path)
}
fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
fs::create_dir_all(&dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
} else {
fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
}
}
Ok(())
}
+9 -12
View File
@@ -6,13 +6,12 @@ edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
build = "build.rs"
[dependencies]
brk_binder = { workspace = true }
brk_bundler = { workspace = true }
anyhow = "1.0"
brk_alloc = { workspace = true }
brk_computer = { workspace = true }
brk_error = { workspace = true }
brk_error = { workspace = true, features = ["tokio", "vecdb"] }
brk_fetcher = { workspace = true }
brk_indexer = { workspace = true }
brk_iterator = { workspace = true }
@@ -22,20 +21,18 @@ brk_query = { workspace = true }
brk_reader = { workspace = true }
brk_rpc = { workspace = true }
brk_server = { workspace = true }
clap = { version = "4.5.53", features = ["derive", "string"] }
color-eyre = { workspace = true }
log = { workspace = true }
mimalloc = { workspace = true }
minreq = { workspace = true }
brk_types = { workspace = true }
lexopt = "0.3"
owo-colors = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }
toml = "0.9.10"
toml = "0.9.11"
vecdb = { workspace = true }
zip = { version = "6.0.0", default-features = false, features = ["deflate"] }
[[bin]]
name = "brk"
path = "src/main.rs"
[package.metadata.dist]
dist = false
dist = true
+57 -32
View File
@@ -1,46 +1,71 @@
# brk_cli
Command-line interface for running the Bitcoin Research Kit.
Command-line interface for running a Bitcoin Research Kit instance.
## What It Enables
## Preview
Run a full BRK instance: index the blockchain, compute metrics, serve the API, and optionally host a web interface. Continuously syncs with new blocks.
- https://bitview.space - web interface
- https://bitview.space/api - API docs
## Key Features
## Requirements
- **All-in-one**: Single binary runs indexer, computer, mempool monitor, and server
- **Auto-sync**: Waits for new blocks and processes them automatically
- **Web interface**: Downloads and bundles frontend from GitHub releases
- **Configurable**: TOML config for RPC, paths, and features
- **Collision checking**: Optional TXID collision validation mode
- **Memory optimized**: Uses mimalloc allocator, 512MB stack for deep recursion
- Bitcoin Core running with RPC enabled
- Access to `blk*.dat` files
- [~400 GB disk space](https://bitview.space/api/server/disk)
- [12+ GB RAM](https://github.com/bitcoinresearchkit/benches#benchmarks)
## Usage
## Install
```bash
# See all options
brk --help
# The CLI will:
# 1. Index new blocks
# 2. Compute derived metrics
# 3. Start mempool monitor
# 4. Launch API server (port 3110)
# 5. Wait for new blocks and repeat
rustup update
RUSTFLAGS="-C target-cpu=native" cargo install --locked brk_cli
```
## Components
Portable build (without native CPU optimizations):
1. **Indexer**: Processes blocks into queryable indexes
2. **Computer**: Derives 1000+ on-chain metrics
3. **Mempool**: Real-time fee estimation
4. **Server**: REST API + MCP endpoint
5. **Bundler**: JS bundling for web interface (if enabled)
```bash
cargo install --locked brk_cli
```
## Built On
## Run
- `brk_indexer` for blockchain indexing
- `brk_computer` for metric computation
- `brk_mempool` for mempool monitoring
- `brk_server` for HTTP API
- `brk_bundler` for web interface bundling
```bash
brk
```
Indexes the blockchain, computes datasets, starts the server on `localhost:3110`, and waits for new blocks.
**Note:** When more than 10,000 blocks behind, indexing completes before the server starts to free up memory from fragmentation that occurs during large syncs. The web interface at `localhost:3110` won't be available until sync finishes.
## Options
```bash
brk -h # Show all options
brk -V # Show version
```
Command-line options override `~/.brk/config.toml` for that run only. Edit the file directly to persist settings:
```toml
brkdir = "/path/to/data"
bitcoindir = "/path/to/.bitcoin"
```
All fields are optional. See `brk -h` for the full list.
## Environment Variables
```bash
LOG=debug brk # Enable debug logging (keeps noise filters)
RUST_LOG=... brk # Full control over log filtering (overrides all defaults)
```
## Files
```
~/.brk/
├── config.toml Configuration
└── log Logs
<brkdir>/ Indexed data (default: ~/.brk)
```
-8
View File
@@ -1,8 +0,0 @@
fn main() {
let profile = std::env::var("PROFILE").unwrap_or_default();
if profile == "release" {
println!("cargo:rustc-flag=-C");
println!("cargo:rustc-flag=target-cpu=native");
}
}
+204 -143
View File
@@ -6,80 +6,52 @@ use std::{
use brk_error::{Error, Result};
use brk_fetcher::Fetcher;
use brk_rpc::{Auth, Client};
use clap::Parser;
use brk_server::Website;
use brk_types::Port;
use owo_colors::OwoColorize;
use serde::{Deserialize, Deserializer, Serialize};
use crate::{default_brk_path, dot_brk_path, website::Website};
use crate::{default_brk_path, dot_brk_path, fix_user_path};
const DOWNLOADS: &str = "downloads";
#[derive(Parser, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
#[command(version, about)]
#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
pub struct Config {
/// Bitcoin main directory path, defaults: ~/.bitcoin, ~/Library/Application\ Support/Bitcoin, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(long, value_name = "PATH")]
bitcoindir: Option<String>,
/// Bitcoin blocks directory path, default: --bitcoindir/blocks, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(long, value_name = "PATH")]
blocksdir: Option<String>,
/// Bitcoin Research Kit outputs directory path, default: ~/.brk, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(long, value_name = "PATH")]
brkdir: Option<String>,
/// Activate fetching prices from BRK's API and the computation of all price related datasets, default: true, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(short = 'F', long, value_name = "BOOL")]
fetch: Option<bool>,
brkport: Option<Port>,
/// Activate fetching prices from exchanges APIs if `fetch` is also set to `true`, default: true, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(long, value_name = "BOOL")]
exchanges: Option<bool>,
/// Website served by the server, default: default, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(short, long)]
website: Option<Website>,
/// Bitcoin RPC ip, default: localhost, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(long, value_name = "IP")]
fetch: Option<bool>,
#[serde(default, deserialize_with = "default_on_error")]
bitcoindir: Option<String>,
#[serde(default, deserialize_with = "default_on_error")]
blocksdir: Option<String>,
#[serde(default, deserialize_with = "default_on_error")]
rpcconnect: Option<String>,
/// Bitcoin RPC port, default: 8332, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(long, value_name = "PORT")]
rpcport: Option<u16>,
/// Bitcoin RPC cookie file, default: --bitcoindir/.cookie, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(long, value_name = "PATH")]
rpccookiefile: Option<String>,
/// Bitcoin RPC username, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(long, value_name = "USERNAME")]
rpcuser: Option<String>,
/// Bitcoin RPC password, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(long, value_name = "PASSWORD")]
rpcpassword: Option<String>,
/// DEV: Activate checking address hashes for collisions when indexing, default: false, saved
#[serde(default, deserialize_with = "default_on_error")]
#[arg(skip)]
check_collisions: Option<bool>,
}
impl Config {
pub fn import() -> Result<Self> {
let config_args = Some(Config::parse());
let config_args = Self::parse_args();
let path = dot_brk_path();
@@ -87,72 +59,193 @@ impl Config {
let path = path.join("config.toml");
let mut config_saved = Self::read(&path);
let mut config = Self::read(&path);
if let Some(mut config_args) = config_args {
if let Some(bitcoindir) = config_args.bitcoindir.take() {
config_saved.bitcoindir = Some(bitcoindir);
}
if let Some(blocksdir) = config_args.blocksdir.take() {
config_saved.blocksdir = Some(blocksdir);
}
if let Some(brkdir) = config_args.brkdir.take() {
config_saved.brkdir = Some(brkdir);
}
if let Some(fetch) = config_args.fetch.take() {
config_saved.fetch = Some(fetch);
}
if let Some(exchanges) = config_args.exchanges.take() {
config_saved.exchanges = Some(exchanges);
}
if let Some(website) = config_args.website.take() {
config_saved.website = Some(website);
}
if let Some(rpcconnect) = config_args.rpcconnect.take() {
config_saved.rpcconnect = Some(rpcconnect);
}
if let Some(rpcport) = config_args.rpcport.take() {
config_saved.rpcport = Some(rpcport);
}
if let Some(rpccookiefile) = config_args.rpccookiefile.take() {
config_saved.rpccookiefile = Some(rpccookiefile);
}
if let Some(rpcuser) = config_args.rpcuser.take() {
config_saved.rpcuser = Some(rpcuser);
}
if let Some(rpcpassword) = config_args.rpcpassword.take() {
config_saved.rpcpassword = Some(rpcpassword);
}
if let Some(check_collisions) = config_args.check_collisions.take() {
config_saved.check_collisions = Some(check_collisions);
}
if config_args != Config::default() {
dbg!(config_args);
panic!("Didn't consume the full config")
}
if let Some(v) = config_args.brkdir {
config.brkdir = Some(v);
}
if let Some(v) = config_args.brkport {
config.brkport = Some(v);
}
if let Some(v) = config_args.website {
config.website = Some(v);
}
if let Some(v) = config_args.fetch {
config.fetch = Some(v);
}
if let Some(v) = config_args.bitcoindir {
config.bitcoindir = Some(v);
}
if let Some(v) = config_args.blocksdir {
config.blocksdir = Some(v);
}
if let Some(v) = config_args.rpcconnect {
config.rpcconnect = Some(v);
}
if let Some(v) = config_args.rpcport {
config.rpcport = Some(v);
}
if let Some(v) = config_args.rpccookiefile {
config.rpccookiefile = Some(v);
}
if let Some(v) = config_args.rpcuser {
config.rpcuser = Some(v);
}
if let Some(v) = config_args.rpcpassword {
config.rpcpassword = Some(v);
}
let config = config_saved;
config.check();
config.write(&path)?;
Ok(config)
}
fn parse_args() -> Self {
use lexopt::prelude::*;
let mut config = Self::default();
let mut parser = lexopt::Parser::from_env();
while let Some(arg) = parser.next().unwrap() {
match arg {
Short('h') | Long("help") => {
Self::print_help();
std::process::exit(0);
}
Short('V') | Long("version") => {
println!("brk {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
Long("brkdir") => config.brkdir = Some(parser.value().unwrap().parse().unwrap()),
Long("brkport") => config.brkport = Some(parser.value().unwrap().parse().unwrap()),
Long("website") => config.website = Some(parser.value().unwrap().parse().unwrap()),
Long("fetch") => config.fetch = Some(parser.value().unwrap().parse().unwrap()),
Long("bitcoindir") => {
config.bitcoindir = Some(parser.value().unwrap().parse().unwrap())
}
Long("blocksdir") => {
config.blocksdir = Some(parser.value().unwrap().parse().unwrap())
}
Long("rpcconnect") => {
config.rpcconnect = Some(parser.value().unwrap().parse().unwrap())
}
Long("rpcport") => config.rpcport = Some(parser.value().unwrap().parse().unwrap()),
Long("rpccookiefile") => {
config.rpccookiefile = Some(parser.value().unwrap().parse().unwrap())
}
Long("rpcuser") => config.rpcuser = Some(parser.value().unwrap().parse().unwrap()),
Long("rpcpassword") => {
config.rpcpassword = Some(parser.value().unwrap().parse().unwrap())
}
_ => {
eprintln!("{}", arg.unexpected());
std::process::exit(1);
}
}
}
config
}
fn print_help() {
let v = env!("CARGO_PKG_VERSION");
println!("{} {}", "brk".bold(), v.bright_black());
println!("Bitcoin Research Kit");
println!();
println!("{}", "USAGE:".bold());
println!(
" {} brk {}",
"[ENV]".bright_black(),
"[OPTIONS]".bright_black()
);
println!();
println!("{}", "OPTIONS:".bold());
println!(" -h, --help Print help");
println!(" -V, --version Print version");
println!();
println!(
" --brkdir {} Output directory {}",
"<PATH>".bright_black(),
"[~/.brk]".bright_black()
);
println!(
" --brkport {} Server port {}",
"<PORT>".bright_black(),
"[3110]".bright_black()
);
println!(
" --website {} Website {}",
"<BOOL|PATH>".bright_black(),
"[true]".bright_black()
);
println!(
" --fetch {} Fetch prices {}",
"<BOOL>".bright_black(),
"[true]".bright_black()
);
println!();
println!(
" --bitcoindir {} Bitcoin directory {}",
"<PATH>".bright_black(),
"[OS default]".bright_black()
);
println!(
" --blocksdir {} Blocks directory {}",
"<PATH>".bright_black(),
"[<bitcoindir>/blocks]".bright_black()
);
println!();
println!(
" --rpcconnect {} RPC host {}",
"<IP>".bright_black(),
"[localhost]".bright_black()
);
println!(
" --rpcport {} RPC port {}",
"<PORT>".bright_black(),
"[8332]".bright_black()
);
println!(
" --rpccookiefile {} RPC cookie file {}",
"<PATH>".bright_black(),
"[<bitcoindir>/.cookie]".bright_black()
);
println!(
" --rpcuser {} RPC username",
"<USERNAME>".bright_black()
);
println!(
" --rpcpassword {} RPC password",
"<PASSWORD>".bright_black()
);
println!();
println!("{}", "ENVIRONMENT:".bold());
println!(
" LOG={} Log level {}",
"<LEVEL>".bright_black(),
"[info]".bright_black()
);
println!(
" RUST_LOG={} Full log filter",
"<RULES>".bright_black()
);
println!();
println!("{}", "CONFIG:".bold());
println!(
" Edit {} to persist settings:",
"~/.brk/config.toml".bright_black()
);
println!(
" {}",
"brkdir = \"/path/to/data\"".bright_black()
);
println!(
" {}",
"bitcoindir = \"/path/to/.bitcoin\"".bright_black()
);
}
fn check(&self) {
if !self.bitcoindir().is_dir() {
println!("{:?} isn't a valid directory", self.bitcoindir());
@@ -192,10 +285,6 @@ Finally, you can run the program with '-h' for help."
)
}
fn write(&self, path: &Path) -> std::io::Result<()> {
fs::write(path, toml::to_string(self).unwrap())
}
pub fn rpc(&self) -> Result<Client> {
Client::new(
&format!(
@@ -233,76 +322,48 @@ Finally, you can run the program with '-h' for help."
pub fn bitcoindir(&self) -> PathBuf {
self.bitcoindir
.as_ref()
.map_or_else(Client::default_bitcoin_path, |s| {
Self::fix_user_path(s.as_ref())
})
.map_or_else(Client::default_bitcoin_path, |s| fix_user_path(s.as_ref()))
}
pub fn blocksdir(&self) -> PathBuf {
self.blocksdir.as_ref().map_or_else(
|| self.bitcoindir().join("blocks"),
|blocksdir| Self::fix_user_path(blocksdir.as_str()),
|blocksdir| fix_user_path(blocksdir.as_str()),
)
}
pub fn brkdir(&self) -> PathBuf {
self.brkdir
.as_ref()
.map_or_else(default_brk_path, |s| Self::fix_user_path(s.as_ref()))
.map_or_else(default_brk_path, |s| fix_user_path(s.as_ref()))
}
pub fn harsdir(&self) -> PathBuf {
self.brkdir().join("hars")
}
pub fn downloads_dir(&self) -> PathBuf {
dot_brk_path().join(DOWNLOADS)
}
fn path_cookiefile(&self) -> PathBuf {
self.rpccookiefile.as_ref().map_or_else(
|| self.bitcoindir().join(".cookie"),
|p| Self::fix_user_path(p.as_str()),
|p| fix_user_path(p.as_str()),
)
}
fn fix_user_path(path: &str) -> PathBuf {
let fix = move |pattern: &str| {
if path.starts_with(pattern) {
let path = &path
.replace(&format!("{pattern}/"), "")
.replace(pattern, "");
let home = std::env::var("HOME").unwrap();
Some(Path::new(&home).join(path))
} else {
None
}
};
fix("~").unwrap_or_else(|| fix("$HOME").unwrap_or_else(|| PathBuf::from(&path)))
pub fn website(&self) -> Website {
self.website.clone().unwrap_or_default()
}
pub fn website(&self) -> Website {
self.website.unwrap_or(Website::Bitview)
pub fn brkport(&self) -> Option<Port> {
self.brkport
}
pub fn fetch(&self) -> bool {
self.fetch.is_none_or(|b| b)
}
pub fn exchanges(&self) -> bool {
self.exchanges.is_none_or(|b| b)
}
pub fn fetcher(&self) -> Option<Fetcher> {
self.fetch()
.then(|| Fetcher::import(self.exchanges(), Some(self.harsdir().as_path())).unwrap())
}
pub fn check_collisions(&self) -> bool {
self.check_collisions.is_some_and(|b| b)
.then(|| Fetcher::import(Some(self.harsdir().as_path())).unwrap())
}
}
+31 -70
View File
@@ -2,14 +2,11 @@
use std::{
fs,
io::Cursor,
path::Path,
thread::{self, sleep},
time::Duration,
};
use brk_binder::generate_js_files;
use brk_bundler::bundle;
use brk_alloc::Mimalloc;
use brk_computer::Computer;
use brk_error::Result;
use brk_indexer::Indexer;
@@ -17,21 +14,16 @@ use brk_iterator::Blocks;
use brk_mempool::Mempool;
use brk_query::AsyncQuery;
use brk_reader::Reader;
use brk_server::{Server, VERSION};
use log::info;
use mimalloc::MiMalloc;
use brk_server::Server;
use tracing::info;
use vecdb::Exit;
mod config;
mod paths;
mod website;
use crate::{config::Config, paths::*};
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
pub fn main() -> color_eyre::Result<()> {
pub fn main() -> anyhow::Result<()> {
// Can't increase main thread's stack size, thus we need to use another thread
thread::Builder::new()
.stack_size(512 * 1024 * 1024)
@@ -40,9 +32,7 @@ pub fn main() -> color_eyre::Result<()> {
.unwrap()
}
pub fn run() -> color_eyre::Result<()> {
color_eyre::install()?;
pub fn run() -> anyhow::Result<()> {
fs::create_dir_all(dot_brk_path())?;
brk_logger::init(Some(&dot_brk_log_path()))?;
@@ -60,6 +50,24 @@ pub fn run() -> color_eyre::Result<()> {
let mut indexer = Indexer::forced_import(&config.brkdir())?;
#[cfg(not(debug_assertions))]
{
// Pre-run indexer if too far behind, then drop and reimport to reduce memory
let chain_height = client.get_last_height()?;
let indexed_height = indexer.vecs.starting_height();
let blocks_behind = chain_height.saturating_sub(*indexed_height);
if blocks_behind > 10_000 {
info!("---");
info!("Indexing {blocks_behind} blocks before starting server...");
info!("---");
sleep(Duration::from_secs(10));
indexer.index(&blocks, &client, &exit)?;
drop(indexer);
Mimalloc::collect();
indexer = Indexer::forced_import(&config.brkdir())?;
}
}
let mut computer = Computer::forced_import(&config.brkdir(), &indexer, config.fetcher())?;
let mempool = Mempool::new(&client);
@@ -71,66 +79,17 @@ pub fn run() -> color_eyre::Result<()> {
let query = AsyncQuery::build(&reader, &indexer, &computer, Some(mempool));
let data_path = config.brkdir();
let website = config.website();
let downloads_path = config.downloads_dir();
let port = config.brkport();
let future = async move {
let bundle_path = if website.is_some() {
let websites_dev_path = Path::new("../../websites");
let modules_dev_path = Path::new("../../modules");
let websites_path;
let modules_path;
if fs::exists(websites_dev_path)? && fs::exists(modules_dev_path)? {
websites_path = websites_dev_path.to_path_buf();
modules_path = modules_dev_path.to_path_buf();
} else {
let downloaded_brk_path = downloads_path.join(format!("brk-{VERSION}"));
let downloaded_websites_path = downloaded_brk_path.join("websites");
let downloaded_modules_path = downloaded_brk_path.join("modules");
if !fs::exists(&downloaded_websites_path)? {
info!("Downloading source from Github...");
let url = format!(
"https://github.com/bitcoinresearchkit/brk/archive/refs/tags/v{VERSION}.zip",
);
let response = minreq::get(url).send()?;
let bytes = response.as_bytes();
let cursor = Cursor::new(bytes);
let mut zip = zip::ZipArchive::new(cursor).unwrap();
zip.extract(downloads_path).unwrap();
}
websites_path = downloaded_websites_path;
modules_path = downloaded_modules_path;
}
generate_js_files(query.inner(), &modules_path)?;
Some(
bundle(
&modules_path,
&websites_path,
website.to_folder_name(),
true,
)
.await?,
)
} else {
None
};
let server = Server::new(&query, bundle_path);
let server = Server::new(&query, data_path, website);
tokio::spawn(async move {
server.serve(true).await.unwrap();
server.serve(port).await.unwrap();
});
Ok(()) as Result<()>
@@ -149,12 +108,14 @@ pub fn run() -> color_eyre::Result<()> {
info!("{} blocks found.", u32::from(last_height) + 1);
let starting_indexes = if config.check_collisions() {
let starting_indexes = if cfg!(debug_assertions) {
indexer.checked_index(&blocks, &client, &exit)?
} else {
indexer.index(&blocks, &client, &exit)?
};
Mimalloc::collect();
computer.compute(&indexer, starting_indexes, &reader, &exit)?;
info!("Waiting for new blocks...");
+9
View File
@@ -12,3 +12,12 @@ pub fn dot_brk_log_path() -> PathBuf {
pub fn default_brk_path() -> PathBuf {
dot_brk_path()
}
pub fn fix_user_path(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/").or(path.strip_prefix("$HOME/"))
&& let Ok(home) = std::env::var("HOME")
{
return PathBuf::from(home).join(rest);
}
PathBuf::from(path)
}
-28
View File
@@ -1,28 +0,0 @@
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Website {
None,
Bitview,
Custom,
}
impl Website {
pub fn is_none(&self) -> bool {
self == &Self::None
}
pub fn is_some(&self) -> bool {
!self.is_none()
}
pub fn to_folder_name(self) -> &'static str {
match self {
Self::Custom => "custom",
Self::Bitview => "bitview",
Self::None => unreachable!(),
}
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "brk_client"
description = "Rust client for the Bitcoin Research Kit API"
version.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
keywords = ["bitcoin", "blockchain", "analytics", "on-chain"]
categories = ["api-bindings", "cryptography::cryptocurrencies"]
[dependencies]
brk_cohort = { workspace = true }
brk_types = { workspace = true }
minreq = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+53
View File
@@ -0,0 +1,53 @@
# brk_client
Rust client for the [Bitcoin Research Kit](https://github.com/bitcoinresearchkit/brk) API.
[crates.io](https://crates.io/crates/brk_client) | [docs.rs](https://docs.rs/brk_client)
## Installation
```toml
[dependencies]
brk_client = "0.1"
```
## Quick Start
```rust
use brk_client::{BrkClient, Index};
fn main() -> brk_client::Result<()> {
let client = BrkClient::new("http://localhost:3110");
// Blockchain data (mempool.space compatible)
let block = client.get_block_by_height(800000)?;
let tx = client.get_tx("abc123...")?;
let address = client.get_address("bc1q...")?;
// Metrics API - typed, chainable
let prices = client.metrics()
.price.usd.split.close
.by.dateindex()
.range(Some(-30), None)?; // Last 30 days
// Generic metric fetching
let data = client.get_metric(
"price_close".into(),
Index::DateIndex,
Some(-30), None, None, None,
)?;
Ok(())
}
```
## Configuration
```rust
use brk_client::{BrkClient, BrkClientOptions};
let client = BrkClient::with_options(BrkClientOptions {
base_url: "http://localhost:3110".to_string(),
timeout_secs: 60,
});
```
+82
View File
@@ -0,0 +1,82 @@
//! Basic example of using the BRK client.
use brk_client::{BrkClient, BrkClientOptions};
use brk_types::{FormatResponse, Index, Metric};
fn main() -> brk_client::Result<()> {
// Create client with default options
let client = BrkClient::new("http://localhost:3110");
// Or with custom options
let _client_with_options = BrkClient::with_options(BrkClientOptions {
base_url: "http://localhost:3110".to_string(),
timeout_secs: 60,
});
// Fetch price data using the typed metrics API
// Using new idiomatic API: last(3).fetch()
let price_close = client
.metrics()
.price
.usd
.split
.close
.by
.dateindex()
.last(3)
.fetch()?;
println!("Last 3 price close values: {:?}", price_close);
// Fetch block data
let block_count = client
.metrics()
.blocks
.count
.block_count
.sum
.by
.dateindex()
.last(3)
.fetch()?;
println!("Last 3 block count values: {:?}", block_count);
// Fetch supply data
dbg!(
client
.metrics()
.supply
.circulating
.bitcoin
.by
.dateindex()
.path()
);
let circulating = client
.metrics()
.supply
.circulating
.bitcoin
.by
.dateindex()
.last(3)
.fetch_csv()?;
println!("Last 3 circulating supply values: {:?}", circulating);
// Using generic metric fetching
let metricdata = client.get_metric(
Metric::from("price_close"),
Index::DateIndex,
Some(-3),
None,
None,
None,
)?;
match metricdata {
FormatResponse::Json(m) => {
println!("Generic fetch result count: {}", m.data.len());
}
FormatResponse::Csv(_) => panic!(),
};
Ok(())
}
@@ -0,0 +1,51 @@
use std::fs::File;
use std::io::{BufWriter, Write};
use brk_client::{BrkClient, BrkClientOptions, Result};
use brk_types::Dollars;
const CHUNK_SIZE: usize = 10_000;
const END_HEIGHT: usize = 630_000;
const OUTPUT_FILE: &str = "prices_avg.txt";
fn main() -> Result<()> {
let client = BrkClient::with_options(BrkClientOptions {
base_url: "https://next.bitview.space".to_string(),
timeout_secs: 60,
});
let file = File::create(OUTPUT_FILE).map_err(|e| brk_client::BrkError {
message: e.to_string(),
})?;
let mut writer = BufWriter::new(file);
for start in (0..END_HEIGHT).step_by(CHUNK_SIZE) {
let end = (start + CHUNK_SIZE).min(END_HEIGHT);
eprintln!("Fetching {start} to {end}...");
let ohlcs = client
.metrics()
.price
.cents
.ohlc
.by
.height()
.range(start..end)
.fetch()?;
for ohlc in ohlcs.data {
let avg = (u64::from(*ohlc.open) + u64::from(*ohlc.close)) / 2;
let avg = Dollars::from(avg);
writeln!(writer, "{avg}").map_err(|e| brk_client::BrkError {
message: e.to_string(),
})?;
}
}
writer.flush().map_err(|e| brk_client::BrkError {
message: e.to_string(),
})?;
eprintln!("Done. Output in {OUTPUT_FILE}");
Ok(())
}
+85
View File
@@ -0,0 +1,85 @@
//! Comprehensive test that fetches all endpoints in the tree.
//!
//! This example demonstrates how to recursively traverse the metrics catalog tree
//! and fetch data from each endpoint. Run with: cargo run --example tree
use brk_client::BrkClient;
use brk_types::{Index, TreeNode};
use std::collections::BTreeSet;
/// A collected metric with its path and available indexes.
struct CollectedMetric {
path: String,
name: String,
indexes: BTreeSet<Index>,
}
/// Recursively collect all metrics from the tree.
fn collect_metrics(node: &TreeNode, path: &str) -> Vec<CollectedMetric> {
let mut metrics = Vec::new();
match node {
TreeNode::Branch(children) => {
for (key, child) in children {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{}.{}", path, key)
};
metrics.extend(collect_metrics(child, &child_path));
}
}
TreeNode::Leaf(leaf) => {
metrics.push(CollectedMetric {
path: path.to_string(),
name: leaf.name().to_string(),
indexes: leaf.indexes().clone(),
});
}
}
metrics
}
fn main() -> brk_client::Result<()> {
let client = BrkClient::new("http://localhost:3110");
// Get the metrics catalog tree
let tree = client.get_metrics_tree()?;
// Recursively collect all metrics
let metrics = collect_metrics(&tree, "");
println!("\nFound {} metrics", metrics.len());
let mut success = 0;
for metric in &metrics {
for index in &metric.indexes {
let index_str = index.serialize_long();
let full_path = format!("{}.by.{}", metric.path, index_str);
match client.get_metric(
metric.name.as_str().into(),
*index,
None,
Some(0),
None,
None,
) {
Ok(_) => {
success += 1;
println!("OK: {}", full_path);
}
Err(e) => {
println!("FAIL: {} -> {}", full_path, e);
return Err(e);
}
}
}
}
println!("\n=== Results ===");
println!("Success: {}", success);
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,16 @@
[package]
name = "brk_grouper"
description = "Groups used throughout BRK"
name = "brk_cohort"
description = "Cohort definitions used throughout BRK"
version.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
build = "build.rs"
[dependencies]
brk_error = { workspace = true }
brk_error = { workspace = true, features = ["vecdb"] }
brk_types = { workspace = true }
brk_traversable = { workspace = true }
vecdb = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
@@ -1,16 +1,16 @@
# brk_grouper
# brk_cohort
UTXO and address cohort filtering for on-chain analytics.
## What It Enables
Slice the UTXO set and address population by age, amount, output type, halving epoch, or holder classification (STH/LTH). Build complex cohorts by combining filters for metrics like "realized cap of 1+ BTC UTXOs older than 155 days."
Slice the UTXO set and address population by age, amount, output type, halving epoch, or holder classification (STH/LTH). Build complex cohorts by combining filters for metrics like "realized cap of 1+ BTC UTXOs older than 150 days."
## Key Features
- **Age-based**: `TimeFilter::GreaterOrEqual(155)`, `TimeFilter::Range(30..90)`, `TimeFilter::LowerThan(7)`
- **Age-based**: `TimeFilter::GreaterOrEqual(hours)`, `TimeFilter::Range(hours..hours)`, `TimeFilter::LowerThan(hours)`
- **Amount-based**: `AmountFilter::GreaterOrEqual(Sats::_1BTC)`, `AmountFilter::Range(Sats::_100K..Sats::_1M)`
- **Term classification**: `Term::Sth` (short-term holders, <155 days), `Term::Lth` (long-term holders)
- **Term classification**: `Term::Sth` (short-term holders, <150 days), `Term::Lth` (long-term holders)
- **Epoch filters**: Group by halving epoch
- **Type filters**: Segment by output type (P2PKH, P2TR, etc.)
- **Context-aware naming**: Automatic prefix generation (`utxos_`, `addrs_`) based on cohort context
@@ -25,6 +25,7 @@ pub enum Filter {
Time(TimeFilter), // Age-based
Amount(AmountFilter), // Value-based
Epoch(HalvingEpoch), // Halving epoch
Year(Year), // Calendar year
Type(OutputType), // P2PKH, P2TR, etc.
}
```
@@ -32,14 +33,16 @@ pub enum Filter {
## Core API
```rust,ignore
let filter = Filter::Time(TimeFilter::GreaterOrEqual(155));
// TimeFilter values are in hours (e.g., 3600 hours = 150 days)
let filter = Filter::Time(TimeFilter::GreaterOrEqual(3600));
// Check membership
filter.contains_time(200); // true
filter.contains_time(4000); // true (4000 hours > 3600 hours)
filter.contains_amount(sats);
// Generate metric names
filter.to_full_name(CohortContext::Utxo); // "utxos_min_age_155d"
// Generate metric names (via CohortContext)
let ctx = CohortContext::Utxo;
ctx.full_name(&filter, "min_age_150d"); // "utxos_min_age_150d"
```
## Built On
@@ -16,7 +16,7 @@ pub struct AddressGroups<T> {
impl<T> AddressGroups<T> {
pub fn new<F>(mut create: F) -> Self
where
F: FnMut(Filter) -> T,
F: FnMut(Filter, &'static str) -> T,
{
Self {
ge_amount: ByGreatEqualAmount::new(&mut create),
@@ -25,6 +25,17 @@ impl<T> AddressGroups<T> {
}
}
pub fn try_new<F, E>(create: &F) -> Result<Self, E>
where
F: Fn(Filter, &'static str) -> Result<T, E>,
{
Ok(Self {
ge_amount: ByGreatEqualAmount::try_new(create)?,
amount_range: ByAmountRange::try_new(create)?,
lt_amount: ByLowerThanAmount::try_new(create)?,
})
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
self.ge_amount
.iter()
+36
View File
@@ -0,0 +1,36 @@
use std::ops::Range;
use brk_types::Sats;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AmountFilter {
LowerThan(Sats),
Range(Range<Sats>),
GreaterOrEqual(Sats),
}
impl AmountFilter {
pub fn contains(&self, sats: Sats) -> bool {
match self {
AmountFilter::LowerThan(max) => sats < *max,
AmountFilter::Range(r) => sats >= r.start && sats < r.end,
AmountFilter::GreaterOrEqual(min) => sats >= *min,
}
}
pub fn includes(&self, other: &AmountFilter) -> bool {
match self {
AmountFilter::LowerThan(max) => match other {
AmountFilter::LowerThan(max2) => max >= max2,
AmountFilter::Range(range) => range.end <= *max,
AmountFilter::GreaterOrEqual(_) => false,
},
AmountFilter::GreaterOrEqual(min) => match other {
AmountFilter::Range(range) => range.start >= *min,
AmountFilter::GreaterOrEqual(min2) => min <= min2,
AmountFilter::LowerThan(_) => false,
},
AmountFilter::Range(_) => false,
}
}
}
@@ -1,10 +1,9 @@
use std::ops::{Add, AddAssign};
use brk_error::Result;
use brk_traversable::{Traversable, TreeNode};
use brk_traversable::Traversable;
use brk_types::OutputType;
use rayon::prelude::*;
use vecdb::AnyExportableVec;
use super::Filter;
@@ -17,7 +16,7 @@ pub const P2WSH: &str = "p2wsh";
pub const P2TR: &str = "p2tr";
pub const P2A: &str = "p2a";
#[derive(Default, Clone, Debug)]
#[derive(Default, Clone, Debug, Traversable)]
pub struct ByAddressType<T> {
pub p2pk65: T,
pub p2pk33: T,
@@ -274,35 +273,40 @@ impl<T> ByAddressType<Option<T>> {
}
}
impl<T: Traversable> Traversable for ByAddressType<T> {
fn to_tree_node(&self) -> TreeNode {
TreeNode::Branch(
[
(P2PK65, &self.p2pk65),
(P2PK33, &self.p2pk33),
(P2PKH, &self.p2pkh),
(P2SH, &self.p2sh),
(P2WPKH, &self.p2wpkh),
(P2WSH, &self.p2wsh),
(P2TR, &self.p2tr),
(P2A, &self.p2a),
]
.into_iter()
.map(|(name, field)| (name.to_string(), field.to_tree_node()))
.collect(),
)
}
fn iter_any_exportable(&self) -> impl Iterator<Item = &dyn AnyExportableVec> {
let mut iter: Box<dyn Iterator<Item = &dyn AnyExportableVec>> =
Box::new(self.p2pk65.iter_any_exportable());
iter = Box::new(iter.chain(self.p2pk33.iter_any_exportable()));
iter = Box::new(iter.chain(self.p2pkh.iter_any_exportable()));
iter = Box::new(iter.chain(self.p2sh.iter_any_exportable()));
iter = Box::new(iter.chain(self.p2wpkh.iter_any_exportable()));
iter = Box::new(iter.chain(self.p2wsh.iter_any_exportable()));
iter = Box::new(iter.chain(self.p2tr.iter_any_exportable()));
iter = Box::new(iter.chain(self.p2a.iter_any_exportable()));
iter
}
/// Zip one ByAddressType with a function, producing a new ByAddressType.
pub fn zip_by_addresstype<S, R, F>(source: &ByAddressType<S>, f: F) -> Result<ByAddressType<R>>
where
F: Fn(&'static str, &S) -> Result<R>,
{
Ok(ByAddressType {
p2pk65: f(P2PK65, &source.p2pk65)?,
p2pk33: f(P2PK33, &source.p2pk33)?,
p2pkh: f(P2PKH, &source.p2pkh)?,
p2sh: f(P2SH, &source.p2sh)?,
p2wpkh: f(P2WPKH, &source.p2wpkh)?,
p2wsh: f(P2WSH, &source.p2wsh)?,
p2tr: f(P2TR, &source.p2tr)?,
p2a: f(P2A, &source.p2a)?,
})
}
/// Zip two ByAddressTypes with a function, producing a new ByAddressType.
pub fn zip2_by_addresstype<S1, S2, R, F>(
a: &ByAddressType<S1>,
b: &ByAddressType<S2>,
f: F,
) -> Result<ByAddressType<R>>
where
F: Fn(&'static str, &S1, &S2) -> Result<R>,
{
Ok(ByAddressType {
p2pk65: f(P2PK65, &a.p2pk65, &b.p2pk65)?,
p2pk33: f(P2PK33, &a.p2pk33, &b.p2pk33)?,
p2pkh: f(P2PKH, &a.p2pkh, &b.p2pkh)?,
p2sh: f(P2SH, &a.p2sh, &b.p2sh)?,
p2wpkh: f(P2WPKH, &a.p2wpkh, &b.p2wpkh)?,
p2wsh: f(P2WSH, &a.p2wsh, &b.p2wsh)?,
p2tr: f(P2TR, &a.p2tr, &b.p2tr)?,
p2a: f(P2A, &a.p2a, &b.p2a)?,
})
}
+348
View File
@@ -0,0 +1,348 @@
use std::ops::Range;
use brk_types::Age;
use brk_traversable::Traversable;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use serde::Serialize;
use super::{CohortName, Filter, TimeFilter};
// Age boundary constants in hours
pub const HOURS_1H: usize = 1;
pub const HOURS_1D: usize = 24;
pub const HOURS_1W: usize = 24 * 7;
pub const HOURS_1M: usize = 24 * 30;
pub const HOURS_2M: usize = 24 * 2 * 30;
pub const HOURS_3M: usize = 24 * 3 * 30;
pub const HOURS_4M: usize = 24 * 4 * 30;
pub const HOURS_5M: usize = 24 * 5 * 30; // STH/LTH threshold
pub const HOURS_6M: usize = 24 * 6 * 30;
pub const HOURS_1Y: usize = 24 * 365;
pub const HOURS_2Y: usize = 24 * 2 * 365;
pub const HOURS_3Y: usize = 24 * 3 * 365;
pub const HOURS_4Y: usize = 24 * 4 * 365;
pub const HOURS_5Y: usize = 24 * 5 * 365;
pub const HOURS_6Y: usize = 24 * 6 * 365;
pub const HOURS_7Y: usize = 24 * 7 * 365;
pub const HOURS_8Y: usize = 24 * 8 * 365;
pub const HOURS_10Y: usize = 24 * 10 * 365;
pub const HOURS_12Y: usize = 24 * 12 * 365;
pub const HOURS_15Y: usize = 24 * 15 * 365;
/// Age boundaries in hours. Defines the cohort ranges:
/// [0, 1h), [1h, 1d), [1d, 1w), [1w, 1m), ..., [15y, ∞)
pub const AGE_BOUNDARIES: [usize; 20] = [
HOURS_1H, HOURS_1D, HOURS_1W, HOURS_1M, HOURS_2M, HOURS_3M, HOURS_4M,
HOURS_5M, HOURS_6M, HOURS_1Y, HOURS_2Y, HOURS_3Y, HOURS_4Y, HOURS_5Y,
HOURS_6Y, HOURS_7Y, HOURS_8Y, HOURS_10Y, HOURS_12Y, HOURS_15Y,
];
/// Age range bounds (end = usize::MAX means unbounded)
pub const AGE_RANGE_BOUNDS: ByAgeRange<Range<usize>> = ByAgeRange {
up_to_1h: 0..HOURS_1H,
_1h_to_1d: HOURS_1H..HOURS_1D,
_1d_to_1w: HOURS_1D..HOURS_1W,
_1w_to_1m: HOURS_1W..HOURS_1M,
_1m_to_2m: HOURS_1M..HOURS_2M,
_2m_to_3m: HOURS_2M..HOURS_3M,
_3m_to_4m: HOURS_3M..HOURS_4M,
_4m_to_5m: HOURS_4M..HOURS_5M,
_5m_to_6m: HOURS_5M..HOURS_6M,
_6m_to_1y: HOURS_6M..HOURS_1Y,
_1y_to_2y: HOURS_1Y..HOURS_2Y,
_2y_to_3y: HOURS_2Y..HOURS_3Y,
_3y_to_4y: HOURS_3Y..HOURS_4Y,
_4y_to_5y: HOURS_4Y..HOURS_5Y,
_5y_to_6y: HOURS_5Y..HOURS_6Y,
_6y_to_7y: HOURS_6Y..HOURS_7Y,
_7y_to_8y: HOURS_7Y..HOURS_8Y,
_8y_to_10y: HOURS_8Y..HOURS_10Y,
_10y_to_12y: HOURS_10Y..HOURS_12Y,
_12y_to_15y: HOURS_12Y..HOURS_15Y,
from_15y: HOURS_15Y..usize::MAX,
};
/// Age range filters
pub const AGE_RANGE_FILTERS: ByAgeRange<Filter> = ByAgeRange {
up_to_1h: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS.up_to_1h)),
_1h_to_1d: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._1h_to_1d)),
_1d_to_1w: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._1d_to_1w)),
_1w_to_1m: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._1w_to_1m)),
_1m_to_2m: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._1m_to_2m)),
_2m_to_3m: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._2m_to_3m)),
_3m_to_4m: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._3m_to_4m)),
_4m_to_5m: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._4m_to_5m)),
_5m_to_6m: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._5m_to_6m)),
_6m_to_1y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._6m_to_1y)),
_1y_to_2y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._1y_to_2y)),
_2y_to_3y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._2y_to_3y)),
_3y_to_4y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._3y_to_4y)),
_4y_to_5y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._4y_to_5y)),
_5y_to_6y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._5y_to_6y)),
_6y_to_7y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._6y_to_7y)),
_7y_to_8y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._7y_to_8y)),
_8y_to_10y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._8y_to_10y)),
_10y_to_12y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._10y_to_12y)),
_12y_to_15y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS._12y_to_15y)),
from_15y: Filter::Time(TimeFilter::Range(AGE_RANGE_BOUNDS.from_15y)),
};
/// Age range names
pub const AGE_RANGE_NAMES: ByAgeRange<CohortName> = ByAgeRange {
up_to_1h: CohortName::new("under_1h_old", "<1h", "Under 1 Hour Old"),
_1h_to_1d: CohortName::new("1h_to_1d_old", "1h-1d", "1 Hour to 1 Day Old"),
_1d_to_1w: CohortName::new("1d_to_1w_old", "1d-1w", "1 Day to 1 Week Old"),
_1w_to_1m: CohortName::new("1w_to_1m_old", "1w-1m", "1 Week to 1 Month Old"),
_1m_to_2m: CohortName::new("1m_to_2m_old", "1m-2m", "1 to 2 Months Old"),
_2m_to_3m: CohortName::new("2m_to_3m_old", "2m-3m", "2 to 3 Months Old"),
_3m_to_4m: CohortName::new("3m_to_4m_old", "3m-4m", "3 to 4 Months Old"),
_4m_to_5m: CohortName::new("4m_to_5m_old", "4m-5m", "4 to 5 Months Old"),
_5m_to_6m: CohortName::new("5m_to_6m_old", "5m-6m", "5 to 6 Months Old"),
_6m_to_1y: CohortName::new("6m_to_1y_old", "6m-1y", "6 Months to 1 Year Old"),
_1y_to_2y: CohortName::new("1y_to_2y_old", "1y-2y", "1 to 2 Years Old"),
_2y_to_3y: CohortName::new("2y_to_3y_old", "2y-3y", "2 to 3 Years Old"),
_3y_to_4y: CohortName::new("3y_to_4y_old", "3y-4y", "3 to 4 Years Old"),
_4y_to_5y: CohortName::new("4y_to_5y_old", "4y-5y", "4 to 5 Years Old"),
_5y_to_6y: CohortName::new("5y_to_6y_old", "5y-6y", "5 to 6 Years Old"),
_6y_to_7y: CohortName::new("6y_to_7y_old", "6y-7y", "6 to 7 Years Old"),
_7y_to_8y: CohortName::new("7y_to_8y_old", "7y-8y", "7 to 8 Years Old"),
_8y_to_10y: CohortName::new("8y_to_10y_old", "8y-10y", "8 to 10 Years Old"),
_10y_to_12y: CohortName::new("10y_to_12y_old", "10y-12y", "10 to 12 Years Old"),
_12y_to_15y: CohortName::new("12y_to_15y_old", "12y-15y", "12 to 15 Years Old"),
from_15y: CohortName::new("over_15y_old", "15y+", "15+ Years Old"),
};
impl ByAgeRange<CohortName> {
pub const fn names() -> &'static Self {
&AGE_RANGE_NAMES
}
}
#[derive(Default, Clone, Traversable, Serialize)]
pub struct ByAgeRange<T> {
pub up_to_1h: T,
pub _1h_to_1d: T,
pub _1d_to_1w: T,
pub _1w_to_1m: T,
pub _1m_to_2m: T,
pub _2m_to_3m: T,
pub _3m_to_4m: T,
pub _4m_to_5m: T,
pub _5m_to_6m: T,
pub _6m_to_1y: T,
pub _1y_to_2y: T,
pub _2y_to_3y: T,
pub _3y_to_4y: T,
pub _4y_to_5y: T,
pub _5y_to_6y: T,
pub _6y_to_7y: T,
pub _7y_to_8y: T,
pub _8y_to_10y: T,
pub _10y_to_12y: T,
pub _12y_to_15y: T,
pub from_15y: T,
}
impl<T> ByAgeRange<T> {
/// Get mutable reference by Age. O(1).
#[inline]
pub fn get_mut(&mut self, age: Age) -> &mut T {
match age.hours() {
0..HOURS_1H => &mut self.up_to_1h,
HOURS_1H..HOURS_1D => &mut self._1h_to_1d,
HOURS_1D..HOURS_1W => &mut self._1d_to_1w,
HOURS_1W..HOURS_1M => &mut self._1w_to_1m,
HOURS_1M..HOURS_2M => &mut self._1m_to_2m,
HOURS_2M..HOURS_3M => &mut self._2m_to_3m,
HOURS_3M..HOURS_4M => &mut self._3m_to_4m,
HOURS_4M..HOURS_5M => &mut self._4m_to_5m,
HOURS_5M..HOURS_6M => &mut self._5m_to_6m,
HOURS_6M..HOURS_1Y => &mut self._6m_to_1y,
HOURS_1Y..HOURS_2Y => &mut self._1y_to_2y,
HOURS_2Y..HOURS_3Y => &mut self._2y_to_3y,
HOURS_3Y..HOURS_4Y => &mut self._3y_to_4y,
HOURS_4Y..HOURS_5Y => &mut self._4y_to_5y,
HOURS_5Y..HOURS_6Y => &mut self._5y_to_6y,
HOURS_6Y..HOURS_7Y => &mut self._6y_to_7y,
HOURS_7Y..HOURS_8Y => &mut self._7y_to_8y,
HOURS_8Y..HOURS_10Y => &mut self._8y_to_10y,
HOURS_10Y..HOURS_12Y => &mut self._10y_to_12y,
HOURS_12Y..HOURS_15Y => &mut self._12y_to_15y,
_ => &mut self.from_15y,
}
}
/// Get reference by Age. O(1).
#[inline]
pub fn get(&self, age: Age) -> &T {
match age.hours() {
0..HOURS_1H => &self.up_to_1h,
HOURS_1H..HOURS_1D => &self._1h_to_1d,
HOURS_1D..HOURS_1W => &self._1d_to_1w,
HOURS_1W..HOURS_1M => &self._1w_to_1m,
HOURS_1M..HOURS_2M => &self._1m_to_2m,
HOURS_2M..HOURS_3M => &self._2m_to_3m,
HOURS_3M..HOURS_4M => &self._3m_to_4m,
HOURS_4M..HOURS_5M => &self._4m_to_5m,
HOURS_5M..HOURS_6M => &self._5m_to_6m,
HOURS_6M..HOURS_1Y => &self._6m_to_1y,
HOURS_1Y..HOURS_2Y => &self._1y_to_2y,
HOURS_2Y..HOURS_3Y => &self._2y_to_3y,
HOURS_3Y..HOURS_4Y => &self._3y_to_4y,
HOURS_4Y..HOURS_5Y => &self._4y_to_5y,
HOURS_5Y..HOURS_6Y => &self._5y_to_6y,
HOURS_6Y..HOURS_7Y => &self._6y_to_7y,
HOURS_7Y..HOURS_8Y => &self._7y_to_8y,
HOURS_8Y..HOURS_10Y => &self._8y_to_10y,
HOURS_10Y..HOURS_12Y => &self._10y_to_12y,
HOURS_12Y..HOURS_15Y => &self._12y_to_15y,
_ => &self.from_15y,
}
}
pub fn new<F>(mut create: F) -> Self
where
F: FnMut(Filter, &'static str) -> T,
{
let f = AGE_RANGE_FILTERS;
let n = AGE_RANGE_NAMES;
Self {
up_to_1h: create(f.up_to_1h.clone(), n.up_to_1h.id),
_1h_to_1d: create(f._1h_to_1d.clone(), n._1h_to_1d.id),
_1d_to_1w: create(f._1d_to_1w.clone(), n._1d_to_1w.id),
_1w_to_1m: create(f._1w_to_1m.clone(), n._1w_to_1m.id),
_1m_to_2m: create(f._1m_to_2m.clone(), n._1m_to_2m.id),
_2m_to_3m: create(f._2m_to_3m.clone(), n._2m_to_3m.id),
_3m_to_4m: create(f._3m_to_4m.clone(), n._3m_to_4m.id),
_4m_to_5m: create(f._4m_to_5m.clone(), n._4m_to_5m.id),
_5m_to_6m: create(f._5m_to_6m.clone(), n._5m_to_6m.id),
_6m_to_1y: create(f._6m_to_1y.clone(), n._6m_to_1y.id),
_1y_to_2y: create(f._1y_to_2y.clone(), n._1y_to_2y.id),
_2y_to_3y: create(f._2y_to_3y.clone(), n._2y_to_3y.id),
_3y_to_4y: create(f._3y_to_4y.clone(), n._3y_to_4y.id),
_4y_to_5y: create(f._4y_to_5y.clone(), n._4y_to_5y.id),
_5y_to_6y: create(f._5y_to_6y.clone(), n._5y_to_6y.id),
_6y_to_7y: create(f._6y_to_7y.clone(), n._6y_to_7y.id),
_7y_to_8y: create(f._7y_to_8y.clone(), n._7y_to_8y.id),
_8y_to_10y: create(f._8y_to_10y.clone(), n._8y_to_10y.id),
_10y_to_12y: create(f._10y_to_12y.clone(), n._10y_to_12y.id),
_12y_to_15y: create(f._12y_to_15y.clone(), n._12y_to_15y.id),
from_15y: create(f.from_15y.clone(), n.from_15y.id),
}
}
pub fn try_new<F, E>(mut create: F) -> Result<Self, E>
where
F: FnMut(Filter, &'static str) -> Result<T, E>,
{
let f = AGE_RANGE_FILTERS;
let n = AGE_RANGE_NAMES;
Ok(Self {
up_to_1h: create(f.up_to_1h.clone(), n.up_to_1h.id)?,
_1h_to_1d: create(f._1h_to_1d.clone(), n._1h_to_1d.id)?,
_1d_to_1w: create(f._1d_to_1w.clone(), n._1d_to_1w.id)?,
_1w_to_1m: create(f._1w_to_1m.clone(), n._1w_to_1m.id)?,
_1m_to_2m: create(f._1m_to_2m.clone(), n._1m_to_2m.id)?,
_2m_to_3m: create(f._2m_to_3m.clone(), n._2m_to_3m.id)?,
_3m_to_4m: create(f._3m_to_4m.clone(), n._3m_to_4m.id)?,
_4m_to_5m: create(f._4m_to_5m.clone(), n._4m_to_5m.id)?,
_5m_to_6m: create(f._5m_to_6m.clone(), n._5m_to_6m.id)?,
_6m_to_1y: create(f._6m_to_1y.clone(), n._6m_to_1y.id)?,
_1y_to_2y: create(f._1y_to_2y.clone(), n._1y_to_2y.id)?,
_2y_to_3y: create(f._2y_to_3y.clone(), n._2y_to_3y.id)?,
_3y_to_4y: create(f._3y_to_4y.clone(), n._3y_to_4y.id)?,
_4y_to_5y: create(f._4y_to_5y.clone(), n._4y_to_5y.id)?,
_5y_to_6y: create(f._5y_to_6y.clone(), n._5y_to_6y.id)?,
_6y_to_7y: create(f._6y_to_7y.clone(), n._6y_to_7y.id)?,
_7y_to_8y: create(f._7y_to_8y.clone(), n._7y_to_8y.id)?,
_8y_to_10y: create(f._8y_to_10y.clone(), n._8y_to_10y.id)?,
_10y_to_12y: create(f._10y_to_12y.clone(), n._10y_to_12y.id)?,
_12y_to_15y: create(f._12y_to_15y.clone(), n._12y_to_15y.id)?,
from_15y: create(f.from_15y.clone(), n.from_15y.id)?,
})
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
[
&self.up_to_1h,
&self._1h_to_1d,
&self._1d_to_1w,
&self._1w_to_1m,
&self._1m_to_2m,
&self._2m_to_3m,
&self._3m_to_4m,
&self._4m_to_5m,
&self._5m_to_6m,
&self._6m_to_1y,
&self._1y_to_2y,
&self._2y_to_3y,
&self._3y_to_4y,
&self._4y_to_5y,
&self._5y_to_6y,
&self._6y_to_7y,
&self._7y_to_8y,
&self._8y_to_10y,
&self._10y_to_12y,
&self._12y_to_15y,
&self.from_15y,
]
.into_iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
[
&mut self.up_to_1h,
&mut self._1h_to_1d,
&mut self._1d_to_1w,
&mut self._1w_to_1m,
&mut self._1m_to_2m,
&mut self._2m_to_3m,
&mut self._3m_to_4m,
&mut self._4m_to_5m,
&mut self._5m_to_6m,
&mut self._6m_to_1y,
&mut self._1y_to_2y,
&mut self._2y_to_3y,
&mut self._3y_to_4y,
&mut self._4y_to_5y,
&mut self._5y_to_6y,
&mut self._6y_to_7y,
&mut self._7y_to_8y,
&mut self._8y_to_10y,
&mut self._10y_to_12y,
&mut self._12y_to_15y,
&mut self.from_15y,
]
.into_iter()
}
pub fn par_iter_mut(&mut self) -> impl ParallelIterator<Item = &mut T>
where
T: Send + Sync,
{
[
&mut self.up_to_1h,
&mut self._1h_to_1d,
&mut self._1d_to_1w,
&mut self._1w_to_1m,
&mut self._1m_to_2m,
&mut self._2m_to_3m,
&mut self._3m_to_4m,
&mut self._4m_to_5m,
&mut self._5m_to_6m,
&mut self._6m_to_1y,
&mut self._1y_to_2y,
&mut self._2y_to_3y,
&mut self._3y_to_4y,
&mut self._4y_to_5y,
&mut self._5y_to_6y,
&mut self._6y_to_7y,
&mut self._7y_to_8y,
&mut self._8y_to_10y,
&mut self._10y_to_12y,
&mut self._12y_to_15y,
&mut self.from_15y,
]
.into_par_iter()
}
}
+443
View File
@@ -0,0 +1,443 @@
use std::ops::{Add, AddAssign, Range};
use brk_traversable::Traversable;
use brk_types::Sats;
use rayon::prelude::*;
use serde::Serialize;
use super::{AmountFilter, CohortName, Filter};
/// Bucket index for amount ranges. Use for cheap comparisons and direct lookups.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AmountBucket(u8);
impl AmountBucket {
/// Returns (self, other) if buckets differ, None if same.
/// Use with `ByAmountRange::get_mut_by_bucket` to avoid recomputing.
#[inline(always)]
pub fn transition_to(self, other: Self) -> Option<(Self, Self)> {
if self != other {
Some((self, other))
} else {
None
}
}
#[inline(always)]
pub fn index(self) -> u8 {
self.0
}
}
impl From<Sats> for AmountBucket {
#[inline(always)]
fn from(value: Sats) -> Self {
Self(match value {
v if v < Sats::_1 => 0,
v if v < Sats::_10 => 1,
v if v < Sats::_100 => 2,
v if v < Sats::_1K => 3,
v if v < Sats::_10K => 4,
v if v < Sats::_100K => 5,
v if v < Sats::_1M => 6,
v if v < Sats::_10M => 7,
v if v < Sats::_1BTC => 8,
v if v < Sats::_10BTC => 9,
v if v < Sats::_100BTC => 10,
v if v < Sats::_1K_BTC => 11,
v if v < Sats::_10K_BTC => 12,
v if v < Sats::_100K_BTC => 13,
_ => 14,
})
}
}
/// Check if two amounts are in different buckets. O(1).
#[inline(always)]
pub fn amounts_in_different_buckets(a: Sats, b: Sats) -> bool {
AmountBucket::from(a) != AmountBucket::from(b)
}
/// Amount range bounds
pub const AMOUNT_RANGE_BOUNDS: ByAmountRange<Range<Sats>> = ByAmountRange {
_0sats: Sats::ZERO..Sats::_1,
_1sat_to_10sats: Sats::_1..Sats::_10,
_10sats_to_100sats: Sats::_10..Sats::_100,
_100sats_to_1k_sats: Sats::_100..Sats::_1K,
_1k_sats_to_10k_sats: Sats::_1K..Sats::_10K,
_10k_sats_to_100k_sats: Sats::_10K..Sats::_100K,
_100k_sats_to_1m_sats: Sats::_100K..Sats::_1M,
_1m_sats_to_10m_sats: Sats::_1M..Sats::_10M,
_10m_sats_to_1btc: Sats::_10M..Sats::_1BTC,
_1btc_to_10btc: Sats::_1BTC..Sats::_10BTC,
_10btc_to_100btc: Sats::_10BTC..Sats::_100BTC,
_100btc_to_1k_btc: Sats::_100BTC..Sats::_1K_BTC,
_1k_btc_to_10k_btc: Sats::_1K_BTC..Sats::_10K_BTC,
_10k_btc_to_100k_btc: Sats::_10K_BTC..Sats::_100K_BTC,
_100k_btc_or_more: Sats::_100K_BTC..Sats::MAX,
};
/// Amount range names
pub const AMOUNT_RANGE_NAMES: ByAmountRange<CohortName> = ByAmountRange {
_0sats: CohortName::new("with_0sats", "0 sats", "0 Sats"),
_1sat_to_10sats: CohortName::new("above_1sat_under_10sats", "1-10 sats", "1-10 Sats"),
_10sats_to_100sats: CohortName::new(
"above_10sats_under_100sats",
"10-100 sats",
"10-100 Sats",
),
_100sats_to_1k_sats: CohortName::new(
"above_100sats_under_1k_sats",
"100-1k sats",
"100-1K Sats",
),
_1k_sats_to_10k_sats: CohortName::new(
"above_1k_sats_under_10k_sats",
"1k-10k sats",
"1K-10K Sats",
),
_10k_sats_to_100k_sats: CohortName::new(
"above_10k_sats_under_100k_sats",
"10k-100k sats",
"10K-100K Sats",
),
_100k_sats_to_1m_sats: CohortName::new(
"above_100k_sats_under_1m_sats",
"100k-1M sats",
"100K-1M Sats",
),
_1m_sats_to_10m_sats: CohortName::new(
"above_1m_sats_under_10m_sats",
"1M-10M sats",
"1M-10M Sats",
),
_10m_sats_to_1btc: CohortName::new("above_10m_sats_under_1btc", "0.1-1 BTC", "0.1-1 BTC"),
_1btc_to_10btc: CohortName::new("above_1btc_under_10btc", "1-10 BTC", "1-10 BTC"),
_10btc_to_100btc: CohortName::new("above_10btc_under_100btc", "10-100 BTC", "10-100 BTC"),
_100btc_to_1k_btc: CohortName::new("above_100btc_under_1k_btc", "100-1k BTC", "100-1K BTC"),
_1k_btc_to_10k_btc: CohortName::new(
"above_1k_btc_under_10k_btc",
"1k-10k BTC",
"1K-10K BTC",
),
_10k_btc_to_100k_btc: CohortName::new(
"above_10k_btc_under_100k_btc",
"10k-100k BTC",
"10K-100K BTC",
),
_100k_btc_or_more: CohortName::new("above_100k_btc", "100k+ BTC", "100K+ BTC"),
};
/// Amount range filters
pub const AMOUNT_RANGE_FILTERS: ByAmountRange<Filter> = ByAmountRange {
_0sats: Filter::Amount(AmountFilter::Range(AMOUNT_RANGE_BOUNDS._0sats)),
_1sat_to_10sats: Filter::Amount(AmountFilter::Range(AMOUNT_RANGE_BOUNDS._1sat_to_10sats)),
_10sats_to_100sats: Filter::Amount(AmountFilter::Range(AMOUNT_RANGE_BOUNDS._10sats_to_100sats)),
_100sats_to_1k_sats: Filter::Amount(AmountFilter::Range(
AMOUNT_RANGE_BOUNDS._100sats_to_1k_sats,
)),
_1k_sats_to_10k_sats: Filter::Amount(AmountFilter::Range(
AMOUNT_RANGE_BOUNDS._1k_sats_to_10k_sats,
)),
_10k_sats_to_100k_sats: Filter::Amount(AmountFilter::Range(
AMOUNT_RANGE_BOUNDS._10k_sats_to_100k_sats,
)),
_100k_sats_to_1m_sats: Filter::Amount(AmountFilter::Range(
AMOUNT_RANGE_BOUNDS._100k_sats_to_1m_sats,
)),
_1m_sats_to_10m_sats: Filter::Amount(AmountFilter::Range(
AMOUNT_RANGE_BOUNDS._1m_sats_to_10m_sats,
)),
_10m_sats_to_1btc: Filter::Amount(AmountFilter::Range(AMOUNT_RANGE_BOUNDS._10m_sats_to_1btc)),
_1btc_to_10btc: Filter::Amount(AmountFilter::Range(AMOUNT_RANGE_BOUNDS._1btc_to_10btc)),
_10btc_to_100btc: Filter::Amount(AmountFilter::Range(AMOUNT_RANGE_BOUNDS._10btc_to_100btc)),
_100btc_to_1k_btc: Filter::Amount(AmountFilter::Range(AMOUNT_RANGE_BOUNDS._100btc_to_1k_btc)),
_1k_btc_to_10k_btc: Filter::Amount(AmountFilter::Range(AMOUNT_RANGE_BOUNDS._1k_btc_to_10k_btc)),
_10k_btc_to_100k_btc: Filter::Amount(AmountFilter::Range(
AMOUNT_RANGE_BOUNDS._10k_btc_to_100k_btc,
)),
_100k_btc_or_more: Filter::Amount(AmountFilter::Range(AMOUNT_RANGE_BOUNDS._100k_btc_or_more)),
};
#[derive(Debug, Default, Clone, Traversable, Serialize)]
pub struct ByAmountRange<T> {
pub _0sats: T,
pub _1sat_to_10sats: T,
pub _10sats_to_100sats: T,
pub _100sats_to_1k_sats: T,
pub _1k_sats_to_10k_sats: T,
pub _10k_sats_to_100k_sats: T,
pub _100k_sats_to_1m_sats: T,
pub _1m_sats_to_10m_sats: T,
pub _10m_sats_to_1btc: T,
pub _1btc_to_10btc: T,
pub _10btc_to_100btc: T,
pub _100btc_to_1k_btc: T,
pub _1k_btc_to_10k_btc: T,
pub _10k_btc_to_100k_btc: T,
pub _100k_btc_or_more: T,
}
impl ByAmountRange<CohortName> {
pub const fn names() -> &'static Self {
&AMOUNT_RANGE_NAMES
}
}
impl<T> ByAmountRange<T> {
pub fn new<F>(mut create: F) -> Self
where
F: FnMut(Filter, &'static str) -> T,
{
let f = AMOUNT_RANGE_FILTERS;
let n = AMOUNT_RANGE_NAMES;
Self {
_0sats: create(f._0sats.clone(), n._0sats.id),
_1sat_to_10sats: create(f._1sat_to_10sats.clone(), n._1sat_to_10sats.id),
_10sats_to_100sats: create(f._10sats_to_100sats.clone(), n._10sats_to_100sats.id),
_100sats_to_1k_sats: create(f._100sats_to_1k_sats.clone(), n._100sats_to_1k_sats.id),
_1k_sats_to_10k_sats: create(f._1k_sats_to_10k_sats.clone(), n._1k_sats_to_10k_sats.id),
_10k_sats_to_100k_sats: create(
f._10k_sats_to_100k_sats.clone(),
n._10k_sats_to_100k_sats.id,
),
_100k_sats_to_1m_sats: create(
f._100k_sats_to_1m_sats.clone(),
n._100k_sats_to_1m_sats.id,
),
_1m_sats_to_10m_sats: create(f._1m_sats_to_10m_sats.clone(), n._1m_sats_to_10m_sats.id),
_10m_sats_to_1btc: create(f._10m_sats_to_1btc.clone(), n._10m_sats_to_1btc.id),
_1btc_to_10btc: create(f._1btc_to_10btc.clone(), n._1btc_to_10btc.id),
_10btc_to_100btc: create(f._10btc_to_100btc.clone(), n._10btc_to_100btc.id),
_100btc_to_1k_btc: create(f._100btc_to_1k_btc.clone(), n._100btc_to_1k_btc.id),
_1k_btc_to_10k_btc: create(f._1k_btc_to_10k_btc.clone(), n._1k_btc_to_10k_btc.id),
_10k_btc_to_100k_btc: create(f._10k_btc_to_100k_btc.clone(), n._10k_btc_to_100k_btc.id),
_100k_btc_or_more: create(f._100k_btc_or_more.clone(), n._100k_btc_or_more.id),
}
}
pub fn try_new<F, E>(mut create: F) -> Result<Self, E>
where
F: FnMut(Filter, &'static str) -> Result<T, E>,
{
let f = AMOUNT_RANGE_FILTERS;
let n = AMOUNT_RANGE_NAMES;
Ok(Self {
_0sats: create(f._0sats.clone(), n._0sats.id)?,
_1sat_to_10sats: create(f._1sat_to_10sats.clone(), n._1sat_to_10sats.id)?,
_10sats_to_100sats: create(f._10sats_to_100sats.clone(), n._10sats_to_100sats.id)?,
_100sats_to_1k_sats: create(f._100sats_to_1k_sats.clone(), n._100sats_to_1k_sats.id)?,
_1k_sats_to_10k_sats: create(
f._1k_sats_to_10k_sats.clone(),
n._1k_sats_to_10k_sats.id,
)?,
_10k_sats_to_100k_sats: create(
f._10k_sats_to_100k_sats.clone(),
n._10k_sats_to_100k_sats.id,
)?,
_100k_sats_to_1m_sats: create(
f._100k_sats_to_1m_sats.clone(),
n._100k_sats_to_1m_sats.id,
)?,
_1m_sats_to_10m_sats: create(
f._1m_sats_to_10m_sats.clone(),
n._1m_sats_to_10m_sats.id,
)?,
_10m_sats_to_1btc: create(f._10m_sats_to_1btc.clone(), n._10m_sats_to_1btc.id)?,
_1btc_to_10btc: create(f._1btc_to_10btc.clone(), n._1btc_to_10btc.id)?,
_10btc_to_100btc: create(f._10btc_to_100btc.clone(), n._10btc_to_100btc.id)?,
_100btc_to_1k_btc: create(f._100btc_to_1k_btc.clone(), n._100btc_to_1k_btc.id)?,
_1k_btc_to_10k_btc: create(f._1k_btc_to_10k_btc.clone(), n._1k_btc_to_10k_btc.id)?,
_10k_btc_to_100k_btc: create(
f._10k_btc_to_100k_btc.clone(),
n._10k_btc_to_100k_btc.id,
)?,
_100k_btc_or_more: create(f._100k_btc_or_more.clone(), n._100k_btc_or_more.id)?,
})
}
#[inline(always)]
pub fn get(&self, value: Sats) -> &T {
match AmountBucket::from(value).0 {
0 => &self._0sats,
1 => &self._1sat_to_10sats,
2 => &self._10sats_to_100sats,
3 => &self._100sats_to_1k_sats,
4 => &self._1k_sats_to_10k_sats,
5 => &self._10k_sats_to_100k_sats,
6 => &self._100k_sats_to_1m_sats,
7 => &self._1m_sats_to_10m_sats,
8 => &self._10m_sats_to_1btc,
9 => &self._1btc_to_10btc,
10 => &self._10btc_to_100btc,
11 => &self._100btc_to_1k_btc,
12 => &self._1k_btc_to_10k_btc,
13 => &self._10k_btc_to_100k_btc,
_ => &self._100k_btc_or_more,
}
}
#[inline(always)]
pub fn get_mut(&mut self, value: Sats) -> &mut T {
self.get_mut_by_bucket(AmountBucket::from(value))
}
/// Get mutable reference by pre-computed bucket index.
/// Use with `AmountBucket::transition_to` to avoid recomputing bucket.
#[inline(always)]
pub fn get_mut_by_bucket(&mut self, bucket: AmountBucket) -> &mut T {
match bucket.0 {
0 => &mut self._0sats,
1 => &mut self._1sat_to_10sats,
2 => &mut self._10sats_to_100sats,
3 => &mut self._100sats_to_1k_sats,
4 => &mut self._1k_sats_to_10k_sats,
5 => &mut self._10k_sats_to_100k_sats,
6 => &mut self._100k_sats_to_1m_sats,
7 => &mut self._1m_sats_to_10m_sats,
8 => &mut self._10m_sats_to_1btc,
9 => &mut self._1btc_to_10btc,
10 => &mut self._10btc_to_100btc,
11 => &mut self._100btc_to_1k_btc,
12 => &mut self._1k_btc_to_10k_btc,
13 => &mut self._10k_btc_to_100k_btc,
_ => &mut self._100k_btc_or_more,
}
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
[
&self._0sats,
&self._1sat_to_10sats,
&self._10sats_to_100sats,
&self._100sats_to_1k_sats,
&self._1k_sats_to_10k_sats,
&self._10k_sats_to_100k_sats,
&self._100k_sats_to_1m_sats,
&self._1m_sats_to_10m_sats,
&self._10m_sats_to_1btc,
&self._1btc_to_10btc,
&self._10btc_to_100btc,
&self._100btc_to_1k_btc,
&self._1k_btc_to_10k_btc,
&self._10k_btc_to_100k_btc,
&self._100k_btc_or_more,
]
.into_iter()
}
pub fn iter_typed(&self) -> impl Iterator<Item = (Sats, &T)> {
[
(Sats::ZERO, &self._0sats),
(Sats::_1, &self._1sat_to_10sats),
(Sats::_10, &self._10sats_to_100sats),
(Sats::_100, &self._100sats_to_1k_sats),
(Sats::_1K, &self._1k_sats_to_10k_sats),
(Sats::_10K, &self._10k_sats_to_100k_sats),
(Sats::_100K, &self._100k_sats_to_1m_sats),
(Sats::_1M, &self._1m_sats_to_10m_sats),
(Sats::_10M, &self._10m_sats_to_1btc),
(Sats::_1BTC, &self._1btc_to_10btc),
(Sats::_10BTC, &self._10btc_to_100btc),
(Sats::_100BTC, &self._100btc_to_1k_btc),
(Sats::_1K_BTC, &self._1k_btc_to_10k_btc),
(Sats::_10K_BTC, &self._10k_btc_to_100k_btc),
(Sats::_100K_BTC, &self._100k_btc_or_more),
]
.into_iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
[
&mut self._0sats,
&mut self._1sat_to_10sats,
&mut self._10sats_to_100sats,
&mut self._100sats_to_1k_sats,
&mut self._1k_sats_to_10k_sats,
&mut self._10k_sats_to_100k_sats,
&mut self._100k_sats_to_1m_sats,
&mut self._1m_sats_to_10m_sats,
&mut self._10m_sats_to_1btc,
&mut self._1btc_to_10btc,
&mut self._10btc_to_100btc,
&mut self._100btc_to_1k_btc,
&mut self._1k_btc_to_10k_btc,
&mut self._10k_btc_to_100k_btc,
&mut self._100k_btc_or_more,
]
.into_iter()
}
pub fn par_iter_mut(&mut self) -> impl ParallelIterator<Item = &mut T>
where
T: Send + Sync,
{
[
&mut self._0sats,
&mut self._1sat_to_10sats,
&mut self._10sats_to_100sats,
&mut self._100sats_to_1k_sats,
&mut self._1k_sats_to_10k_sats,
&mut self._10k_sats_to_100k_sats,
&mut self._100k_sats_to_1m_sats,
&mut self._1m_sats_to_10m_sats,
&mut self._10m_sats_to_1btc,
&mut self._1btc_to_10btc,
&mut self._10btc_to_100btc,
&mut self._100btc_to_1k_btc,
&mut self._1k_btc_to_10k_btc,
&mut self._10k_btc_to_100k_btc,
&mut self._100k_btc_or_more,
]
.into_par_iter()
}
}
impl<T> Add for ByAmountRange<T>
where
T: Add<Output = T>,
{
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self {
_0sats: self._0sats + rhs._0sats,
_1sat_to_10sats: self._1sat_to_10sats + rhs._1sat_to_10sats,
_10sats_to_100sats: self._10sats_to_100sats + rhs._10sats_to_100sats,
_100sats_to_1k_sats: self._100sats_to_1k_sats + rhs._100sats_to_1k_sats,
_1k_sats_to_10k_sats: self._1k_sats_to_10k_sats + rhs._1k_sats_to_10k_sats,
_10k_sats_to_100k_sats: self._10k_sats_to_100k_sats + rhs._10k_sats_to_100k_sats,
_100k_sats_to_1m_sats: self._100k_sats_to_1m_sats + rhs._100k_sats_to_1m_sats,
_1m_sats_to_10m_sats: self._1m_sats_to_10m_sats + rhs._1m_sats_to_10m_sats,
_10m_sats_to_1btc: self._10m_sats_to_1btc + rhs._10m_sats_to_1btc,
_1btc_to_10btc: self._1btc_to_10btc + rhs._1btc_to_10btc,
_10btc_to_100btc: self._10btc_to_100btc + rhs._10btc_to_100btc,
_100btc_to_1k_btc: self._100btc_to_1k_btc + rhs._100btc_to_1k_btc,
_1k_btc_to_10k_btc: self._1k_btc_to_10k_btc + rhs._1k_btc_to_10k_btc,
_10k_btc_to_100k_btc: self._10k_btc_to_100k_btc + rhs._10k_btc_to_100k_btc,
_100k_btc_or_more: self._100k_btc_or_more + rhs._100k_btc_or_more,
}
}
}
impl<T> AddAssign for ByAmountRange<T>
where
T: AddAssign,
{
fn add_assign(&mut self, rhs: Self) {
self._0sats += rhs._0sats;
self._1sat_to_10sats += rhs._1sat_to_10sats;
self._10sats_to_100sats += rhs._10sats_to_100sats;
self._100sats_to_1k_sats += rhs._100sats_to_1k_sats;
self._1k_sats_to_10k_sats += rhs._1k_sats_to_10k_sats;
self._10k_sats_to_100k_sats += rhs._10k_sats_to_100k_sats;
self._100k_sats_to_1m_sats += rhs._100k_sats_to_1m_sats;
self._1m_sats_to_10m_sats += rhs._1m_sats_to_10m_sats;
self._10m_sats_to_1btc += rhs._10m_sats_to_1btc;
self._1btc_to_10btc += rhs._1btc_to_10btc;
self._10btc_to_100btc += rhs._10btc_to_100btc;
self._100btc_to_1k_btc += rhs._100btc_to_1k_btc;
self._1k_btc_to_10k_btc += rhs._1k_btc_to_10k_btc;
self._10k_btc_to_100k_btc += rhs._10k_btc_to_100k_btc;
self._100k_btc_or_more += rhs._100k_btc_or_more;
}
}
@@ -2,13 +2,13 @@ use brk_traversable::Traversable;
#[derive(Debug, Default, Traversable)]
pub struct ByAnyAddress<T> {
pub loaded: T,
pub funded: T,
pub empty: T,
}
impl<T> ByAnyAddress<Option<T>> {
pub fn take(&mut self) {
self.loaded.take();
self.funded.take();
self.empty.take();
}
}
+126
View File
@@ -0,0 +1,126 @@
use brk_traversable::Traversable;
use brk_types::{HalvingEpoch, Height};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use serde::Serialize;
use super::{CohortName, Filter};
/// Epoch values
pub const EPOCH_VALUES: ByEpoch<HalvingEpoch> = ByEpoch {
_0: HalvingEpoch::new(0),
_1: HalvingEpoch::new(1),
_2: HalvingEpoch::new(2),
_3: HalvingEpoch::new(3),
_4: HalvingEpoch::new(4),
};
/// Epoch filters
pub const EPOCH_FILTERS: ByEpoch<Filter> = ByEpoch {
_0: Filter::Epoch(EPOCH_VALUES._0),
_1: Filter::Epoch(EPOCH_VALUES._1),
_2: Filter::Epoch(EPOCH_VALUES._2),
_3: Filter::Epoch(EPOCH_VALUES._3),
_4: Filter::Epoch(EPOCH_VALUES._4),
};
/// Epoch names
pub const EPOCH_NAMES: ByEpoch<CohortName> = ByEpoch {
_0: CohortName::new("epoch_0", "0", "Epoch 0"),
_1: CohortName::new("epoch_1", "1", "Epoch 1"),
_2: CohortName::new("epoch_2", "2", "Epoch 2"),
_3: CohortName::new("epoch_3", "3", "Epoch 3"),
_4: CohortName::new("epoch_4", "4", "Epoch 4"),
};
#[derive(Default, Clone, Traversable, Serialize)]
pub struct ByEpoch<T> {
pub _0: T,
pub _1: T,
pub _2: T,
pub _3: T,
pub _4: T,
}
impl ByEpoch<CohortName> {
pub const fn names() -> &'static Self {
&EPOCH_NAMES
}
}
impl<T> ByEpoch<T> {
pub fn new<F>(mut create: F) -> Self
where
F: FnMut(Filter, &'static str) -> T,
{
let f = EPOCH_FILTERS;
let n = EPOCH_NAMES;
Self {
_0: create(f._0, n._0.id),
_1: create(f._1, n._1.id),
_2: create(f._2, n._2.id),
_3: create(f._3, n._3.id),
_4: create(f._4, n._4.id),
}
}
pub fn try_new<F, E>(mut create: F) -> Result<Self, E>
where
F: FnMut(Filter, &'static str) -> Result<T, E>,
{
let f = EPOCH_FILTERS;
let n = EPOCH_NAMES;
Ok(Self {
_0: create(f._0, n._0.id)?,
_1: create(f._1, n._1.id)?,
_2: create(f._2, n._2.id)?,
_3: create(f._3, n._3.id)?,
_4: create(f._4, n._4.id)?,
})
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
[&self._0, &self._1, &self._2, &self._3, &self._4].into_iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
[
&mut self._0,
&mut self._1,
&mut self._2,
&mut self._3,
&mut self._4,
]
.into_iter()
}
pub fn par_iter_mut(&mut self) -> impl ParallelIterator<Item = &mut T>
where
T: Send + Sync,
{
[
&mut self._0,
&mut self._1,
&mut self._2,
&mut self._3,
&mut self._4,
]
.into_par_iter()
}
pub fn mut_vec_from_height(&mut self, height: Height) -> &mut T {
let epoch = HalvingEpoch::from(height);
if epoch == HalvingEpoch::new(0) {
&mut self._0
} else if epoch == HalvingEpoch::new(1) {
&mut self._1
} else if epoch == HalvingEpoch::new(2) {
&mut self._2
} else if epoch == HalvingEpoch::new(3) {
&mut self._3
} else if epoch == HalvingEpoch::new(4) {
&mut self._4
} else {
todo!("")
}
}
}
+190
View File
@@ -0,0 +1,190 @@
use brk_traversable::Traversable;
use brk_types::Sats;
use rayon::prelude::*;
use serde::Serialize;
use super::{AmountFilter, CohortName, Filter};
/// Greater-or-equal amount thresholds
pub const GE_AMOUNT_THRESHOLDS: ByGreatEqualAmount<Sats> = ByGreatEqualAmount {
_1sat: Sats::_1,
_10sats: Sats::_10,
_100sats: Sats::_100,
_1k_sats: Sats::_1K,
_10k_sats: Sats::_10K,
_100k_sats: Sats::_100K,
_1m_sats: Sats::_1M,
_10m_sats: Sats::_10M,
_1btc: Sats::_1BTC,
_10btc: Sats::_10BTC,
_100btc: Sats::_100BTC,
_1k_btc: Sats::_1K_BTC,
_10k_btc: Sats::_10K_BTC,
};
/// Greater-or-equal amount names
pub const GE_AMOUNT_NAMES: ByGreatEqualAmount<CohortName> = ByGreatEqualAmount {
_1sat: CohortName::new("over_1sat", "1+ sats", "Over 1 Sat"),
_10sats: CohortName::new("over_10sats", "10+ sats", "Over 10 Sats"),
_100sats: CohortName::new("over_100sats", "100+ sats", "Over 100 Sats"),
_1k_sats: CohortName::new("over_1k_sats", "1k+ sats", "Over 1K Sats"),
_10k_sats: CohortName::new("over_10k_sats", "10k+ sats", "Over 10K Sats"),
_100k_sats: CohortName::new("over_100k_sats", "100k+ sats", "Over 100K Sats"),
_1m_sats: CohortName::new("over_1m_sats", "1M+ sats", "Over 1M Sats"),
_10m_sats: CohortName::new("over_10m_sats", "0.1+ BTC", "Over 0.1 BTC"),
_1btc: CohortName::new("over_1btc", "1+ BTC", "Over 1 BTC"),
_10btc: CohortName::new("over_10btc", "10+ BTC", "Over 10 BTC"),
_100btc: CohortName::new("over_100btc", "100+ BTC", "Over 100 BTC"),
_1k_btc: CohortName::new("over_1k_btc", "1k+ BTC", "Over 1K BTC"),
_10k_btc: CohortName::new("over_10k_btc", "10k+ BTC", "Over 10K BTC"),
};
/// Greater-or-equal amount filters
pub const GE_AMOUNT_FILTERS: ByGreatEqualAmount<Filter> = ByGreatEqualAmount {
_1sat: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._1sat)),
_10sats: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._10sats)),
_100sats: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._100sats)),
_1k_sats: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._1k_sats)),
_10k_sats: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._10k_sats)),
_100k_sats: Filter::Amount(AmountFilter::GreaterOrEqual(
GE_AMOUNT_THRESHOLDS._100k_sats,
)),
_1m_sats: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._1m_sats)),
_10m_sats: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._10m_sats)),
_1btc: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._1btc)),
_10btc: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._10btc)),
_100btc: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._100btc)),
_1k_btc: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._1k_btc)),
_10k_btc: Filter::Amount(AmountFilter::GreaterOrEqual(GE_AMOUNT_THRESHOLDS._10k_btc)),
};
#[derive(Default, Clone, Traversable, Serialize)]
pub struct ByGreatEqualAmount<T> {
pub _1sat: T,
pub _10sats: T,
pub _100sats: T,
pub _1k_sats: T,
pub _10k_sats: T,
pub _100k_sats: T,
pub _1m_sats: T,
pub _10m_sats: T,
pub _1btc: T,
pub _10btc: T,
pub _100btc: T,
pub _1k_btc: T,
pub _10k_btc: T,
}
impl ByGreatEqualAmount<CohortName> {
pub const fn names() -> &'static Self {
&GE_AMOUNT_NAMES
}
}
impl<T> ByGreatEqualAmount<T> {
pub fn new<F>(mut create: F) -> Self
where
F: FnMut(Filter, &'static str) -> T,
{
let f = GE_AMOUNT_FILTERS;
let n = GE_AMOUNT_NAMES;
Self {
_1sat: create(f._1sat.clone(), n._1sat.id),
_10sats: create(f._10sats.clone(), n._10sats.id),
_100sats: create(f._100sats.clone(), n._100sats.id),
_1k_sats: create(f._1k_sats.clone(), n._1k_sats.id),
_10k_sats: create(f._10k_sats.clone(), n._10k_sats.id),
_100k_sats: create(f._100k_sats.clone(), n._100k_sats.id),
_1m_sats: create(f._1m_sats.clone(), n._1m_sats.id),
_10m_sats: create(f._10m_sats.clone(), n._10m_sats.id),
_1btc: create(f._1btc.clone(), n._1btc.id),
_10btc: create(f._10btc.clone(), n._10btc.id),
_100btc: create(f._100btc.clone(), n._100btc.id),
_1k_btc: create(f._1k_btc.clone(), n._1k_btc.id),
_10k_btc: create(f._10k_btc.clone(), n._10k_btc.id),
}
}
pub fn try_new<F, E>(mut create: F) -> Result<Self, E>
where
F: FnMut(Filter, &'static str) -> Result<T, E>,
{
let f = GE_AMOUNT_FILTERS;
let n = GE_AMOUNT_NAMES;
Ok(Self {
_1sat: create(f._1sat.clone(), n._1sat.id)?,
_10sats: create(f._10sats.clone(), n._10sats.id)?,
_100sats: create(f._100sats.clone(), n._100sats.id)?,
_1k_sats: create(f._1k_sats.clone(), n._1k_sats.id)?,
_10k_sats: create(f._10k_sats.clone(), n._10k_sats.id)?,
_100k_sats: create(f._100k_sats.clone(), n._100k_sats.id)?,
_1m_sats: create(f._1m_sats.clone(), n._1m_sats.id)?,
_10m_sats: create(f._10m_sats.clone(), n._10m_sats.id)?,
_1btc: create(f._1btc.clone(), n._1btc.id)?,
_10btc: create(f._10btc.clone(), n._10btc.id)?,
_100btc: create(f._100btc.clone(), n._100btc.id)?,
_1k_btc: create(f._1k_btc.clone(), n._1k_btc.id)?,
_10k_btc: create(f._10k_btc.clone(), n._10k_btc.id)?,
})
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
[
&self._1sat,
&self._10sats,
&self._100sats,
&self._1k_sats,
&self._10k_sats,
&self._100k_sats,
&self._1m_sats,
&self._10m_sats,
&self._1btc,
&self._10btc,
&self._100btc,
&self._1k_btc,
&self._10k_btc,
]
.into_iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
[
&mut self._1sat,
&mut self._10sats,
&mut self._100sats,
&mut self._1k_sats,
&mut self._10k_sats,
&mut self._100k_sats,
&mut self._1m_sats,
&mut self._10m_sats,
&mut self._1btc,
&mut self._10btc,
&mut self._100btc,
&mut self._1k_btc,
&mut self._10k_btc,
]
.into_iter()
}
pub fn par_iter_mut(&mut self) -> impl ParallelIterator<Item = &mut T>
where
T: Send + Sync,
{
[
&mut self._1sat,
&mut self._10sats,
&mut self._100sats,
&mut self._1k_sats,
&mut self._10k_sats,
&mut self._100k_sats,
&mut self._1m_sats,
&mut self._10m_sats,
&mut self._1btc,
&mut self._10btc,
&mut self._100btc,
&mut self._1k_btc,
&mut self._10k_btc,
]
.into_par_iter()
}
}
+188
View File
@@ -0,0 +1,188 @@
use brk_traversable::Traversable;
use brk_types::Sats;
use rayon::prelude::*;
use serde::Serialize;
use super::{AmountFilter, CohortName, Filter};
/// Lower-than amount thresholds
pub const LT_AMOUNT_THRESHOLDS: ByLowerThanAmount<Sats> = ByLowerThanAmount {
_10sats: Sats::_10,
_100sats: Sats::_100,
_1k_sats: Sats::_1K,
_10k_sats: Sats::_10K,
_100k_sats: Sats::_100K,
_1m_sats: Sats::_1M,
_10m_sats: Sats::_10M,
_1btc: Sats::_1BTC,
_10btc: Sats::_10BTC,
_100btc: Sats::_100BTC,
_1k_btc: Sats::_1K_BTC,
_10k_btc: Sats::_10K_BTC,
_100k_btc: Sats::_100K_BTC,
};
/// Lower-than amount names
pub const LT_AMOUNT_NAMES: ByLowerThanAmount<CohortName> = ByLowerThanAmount {
_10sats: CohortName::new("under_10sats", "<10 sats", "Under 10 Sats"),
_100sats: CohortName::new("under_100sats", "<100 sats", "Under 100 Sats"),
_1k_sats: CohortName::new("under_1k_sats", "<1k sats", "Under 1K Sats"),
_10k_sats: CohortName::new("under_10k_sats", "<10k sats", "Under 10K Sats"),
_100k_sats: CohortName::new("under_100k_sats", "<100k sats", "Under 100K Sats"),
_1m_sats: CohortName::new("under_1m_sats", "<1M sats", "Under 1M Sats"),
_10m_sats: CohortName::new("under_10m_sats", "<0.1 BTC", "Under 0.1 BTC"),
_1btc: CohortName::new("under_1btc", "<1 BTC", "Under 1 BTC"),
_10btc: CohortName::new("under_10btc", "<10 BTC", "Under 10 BTC"),
_100btc: CohortName::new("under_100btc", "<100 BTC", "Under 100 BTC"),
_1k_btc: CohortName::new("under_1k_btc", "<1k BTC", "Under 1K BTC"),
_10k_btc: CohortName::new("under_10k_btc", "<10k BTC", "Under 10K BTC"),
_100k_btc: CohortName::new("under_100k_btc", "<100k BTC", "Under 100K BTC"),
};
/// Lower-than amount filters
pub const LT_AMOUNT_FILTERS: ByLowerThanAmount<Filter> = ByLowerThanAmount {
_10sats: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._10sats)),
_100sats: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._100sats)),
_1k_sats: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._1k_sats)),
_10k_sats: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._10k_sats)),
_100k_sats: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._100k_sats)),
_1m_sats: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._1m_sats)),
_10m_sats: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._10m_sats)),
_1btc: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._1btc)),
_10btc: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._10btc)),
_100btc: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._100btc)),
_1k_btc: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._1k_btc)),
_10k_btc: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._10k_btc)),
_100k_btc: Filter::Amount(AmountFilter::LowerThan(LT_AMOUNT_THRESHOLDS._100k_btc)),
};
#[derive(Default, Clone, Traversable, Serialize)]
pub struct ByLowerThanAmount<T> {
pub _10sats: T,
pub _100sats: T,
pub _1k_sats: T,
pub _10k_sats: T,
pub _100k_sats: T,
pub _1m_sats: T,
pub _10m_sats: T,
pub _1btc: T,
pub _10btc: T,
pub _100btc: T,
pub _1k_btc: T,
pub _10k_btc: T,
pub _100k_btc: T,
}
impl ByLowerThanAmount<CohortName> {
pub const fn names() -> &'static Self {
&LT_AMOUNT_NAMES
}
}
impl<T> ByLowerThanAmount<T> {
pub fn new<F>(mut create: F) -> Self
where
F: FnMut(Filter, &'static str) -> T,
{
let f = LT_AMOUNT_FILTERS;
let n = LT_AMOUNT_NAMES;
Self {
_10sats: create(f._10sats.clone(), n._10sats.id),
_100sats: create(f._100sats.clone(), n._100sats.id),
_1k_sats: create(f._1k_sats.clone(), n._1k_sats.id),
_10k_sats: create(f._10k_sats.clone(), n._10k_sats.id),
_100k_sats: create(f._100k_sats.clone(), n._100k_sats.id),
_1m_sats: create(f._1m_sats.clone(), n._1m_sats.id),
_10m_sats: create(f._10m_sats.clone(), n._10m_sats.id),
_1btc: create(f._1btc.clone(), n._1btc.id),
_10btc: create(f._10btc.clone(), n._10btc.id),
_100btc: create(f._100btc.clone(), n._100btc.id),
_1k_btc: create(f._1k_btc.clone(), n._1k_btc.id),
_10k_btc: create(f._10k_btc.clone(), n._10k_btc.id),
_100k_btc: create(f._100k_btc.clone(), n._100k_btc.id),
}
}
pub fn try_new<F, E>(mut create: F) -> Result<Self, E>
where
F: FnMut(Filter, &'static str) -> Result<T, E>,
{
let f = LT_AMOUNT_FILTERS;
let n = LT_AMOUNT_NAMES;
Ok(Self {
_10sats: create(f._10sats.clone(), n._10sats.id)?,
_100sats: create(f._100sats.clone(), n._100sats.id)?,
_1k_sats: create(f._1k_sats.clone(), n._1k_sats.id)?,
_10k_sats: create(f._10k_sats.clone(), n._10k_sats.id)?,
_100k_sats: create(f._100k_sats.clone(), n._100k_sats.id)?,
_1m_sats: create(f._1m_sats.clone(), n._1m_sats.id)?,
_10m_sats: create(f._10m_sats.clone(), n._10m_sats.id)?,
_1btc: create(f._1btc.clone(), n._1btc.id)?,
_10btc: create(f._10btc.clone(), n._10btc.id)?,
_100btc: create(f._100btc.clone(), n._100btc.id)?,
_1k_btc: create(f._1k_btc.clone(), n._1k_btc.id)?,
_10k_btc: create(f._10k_btc.clone(), n._10k_btc.id)?,
_100k_btc: create(f._100k_btc.clone(), n._100k_btc.id)?,
})
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
[
&self._10sats,
&self._100sats,
&self._1k_sats,
&self._10k_sats,
&self._100k_sats,
&self._1m_sats,
&self._10m_sats,
&self._1btc,
&self._10btc,
&self._100btc,
&self._1k_btc,
&self._10k_btc,
&self._100k_btc,
]
.into_iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
[
&mut self._10sats,
&mut self._100sats,
&mut self._1k_sats,
&mut self._10k_sats,
&mut self._100k_sats,
&mut self._1m_sats,
&mut self._10m_sats,
&mut self._1btc,
&mut self._10btc,
&mut self._100btc,
&mut self._1k_btc,
&mut self._10k_btc,
&mut self._100k_btc,
]
.into_iter()
}
pub fn par_iter_mut(&mut self) -> impl ParallelIterator<Item = &mut T>
where
T: Send + Sync,
{
[
&mut self._10sats,
&mut self._100sats,
&mut self._1k_sats,
&mut self._10k_sats,
&mut self._100k_sats,
&mut self._1m_sats,
&mut self._10m_sats,
&mut self._1btc,
&mut self._10btc,
&mut self._100btc,
&mut self._1k_btc,
&mut self._10k_btc,
&mut self._100k_btc,
]
.into_par_iter()
}
}
+221
View File
@@ -0,0 +1,221 @@
use brk_traversable::Traversable;
use rayon::prelude::*;
use serde::Serialize;
use super::{
CohortName, Filter, TimeFilter, HOURS_10Y, HOURS_12Y, HOURS_15Y, HOURS_1M, HOURS_1W, HOURS_1Y,
HOURS_2M, HOURS_2Y, HOURS_3M, HOURS_3Y, HOURS_4M, HOURS_4Y, HOURS_5M, HOURS_5Y, HOURS_6M,
HOURS_6Y, HOURS_7Y, HOURS_8Y,
};
/// Max age thresholds in hours
pub const MAX_AGE_HOURS: ByMaxAge<usize> = ByMaxAge {
_1w: HOURS_1W,
_1m: HOURS_1M,
_2m: HOURS_2M,
_3m: HOURS_3M,
_4m: HOURS_4M,
_5m: HOURS_5M,
_6m: HOURS_6M,
_1y: HOURS_1Y,
_2y: HOURS_2Y,
_3y: HOURS_3Y,
_4y: HOURS_4Y,
_5y: HOURS_5Y,
_6y: HOURS_6Y,
_7y: HOURS_7Y,
_8y: HOURS_8Y,
_10y: HOURS_10Y,
_12y: HOURS_12Y,
_15y: HOURS_15Y,
};
/// Max age filters (LowerThan threshold in hours)
pub const MAX_AGE_FILTERS: ByMaxAge<Filter> = ByMaxAge {
_1w: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._1w)),
_1m: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._1m)),
_2m: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._2m)),
_3m: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._3m)),
_4m: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._4m)),
_5m: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._5m)),
_6m: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._6m)),
_1y: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._1y)),
_2y: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._2y)),
_3y: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._3y)),
_4y: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._4y)),
_5y: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._5y)),
_6y: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._6y)),
_7y: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._7y)),
_8y: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._8y)),
_10y: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._10y)),
_12y: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._12y)),
_15y: Filter::Time(TimeFilter::LowerThan(MAX_AGE_HOURS._15y)),
};
/// Max age names
pub const MAX_AGE_NAMES: ByMaxAge<CohortName> = ByMaxAge {
_1w: CohortName::new("under_1w_old", "<1w", "Under 1 Week Old"),
_1m: CohortName::new("under_1m_old", "<1m", "Under 1 Month Old"),
_2m: CohortName::new("under_2m_old", "<2m", "Under 2 Months Old"),
_3m: CohortName::new("under_3m_old", "<3m", "Under 3 Months Old"),
_4m: CohortName::new("under_4m_old", "<4m", "Under 4 Months Old"),
_5m: CohortName::new("under_5m_old", "<5m", "Under 5 Months Old"),
_6m: CohortName::new("under_6m_old", "<6m", "Under 6 Months Old"),
_1y: CohortName::new("under_1y_old", "<1y", "Under 1 Year Old"),
_2y: CohortName::new("under_2y_old", "<2y", "Under 2 Years Old"),
_3y: CohortName::new("under_3y_old", "<3y", "Under 3 Years Old"),
_4y: CohortName::new("under_4y_old", "<4y", "Under 4 Years Old"),
_5y: CohortName::new("under_5y_old", "<5y", "Under 5 Years Old"),
_6y: CohortName::new("under_6y_old", "<6y", "Under 6 Years Old"),
_7y: CohortName::new("under_7y_old", "<7y", "Under 7 Years Old"),
_8y: CohortName::new("under_8y_old", "<8y", "Under 8 Years Old"),
_10y: CohortName::new("under_10y_old", "<10y", "Under 10 Years Old"),
_12y: CohortName::new("under_12y_old", "<12y", "Under 12 Years Old"),
_15y: CohortName::new("under_15y_old", "<15y", "Under 15 Years Old"),
};
#[derive(Default, Clone, Traversable, Serialize)]
pub struct ByMaxAge<T> {
pub _1w: T,
pub _1m: T,
pub _2m: T,
pub _3m: T,
pub _4m: T,
pub _5m: T,
pub _6m: T,
pub _1y: T,
pub _2y: T,
pub _3y: T,
pub _4y: T,
pub _5y: T,
pub _6y: T,
pub _7y: T,
pub _8y: T,
pub _10y: T,
pub _12y: T,
pub _15y: T,
}
impl ByMaxAge<CohortName> {
pub const fn names() -> &'static Self {
&MAX_AGE_NAMES
}
}
impl<T> ByMaxAge<T> {
pub fn new<F>(mut create: F) -> Self
where
F: FnMut(Filter, &'static str) -> T,
{
let f = MAX_AGE_FILTERS;
let n = MAX_AGE_NAMES;
Self {
_1w: create(f._1w.clone(), n._1w.id),
_1m: create(f._1m.clone(), n._1m.id),
_2m: create(f._2m.clone(), n._2m.id),
_3m: create(f._3m.clone(), n._3m.id),
_4m: create(f._4m.clone(), n._4m.id),
_5m: create(f._5m.clone(), n._5m.id),
_6m: create(f._6m.clone(), n._6m.id),
_1y: create(f._1y.clone(), n._1y.id),
_2y: create(f._2y.clone(), n._2y.id),
_3y: create(f._3y.clone(), n._3y.id),
_4y: create(f._4y.clone(), n._4y.id),
_5y: create(f._5y.clone(), n._5y.id),
_6y: create(f._6y.clone(), n._6y.id),
_7y: create(f._7y.clone(), n._7y.id),
_8y: create(f._8y.clone(), n._8y.id),
_10y: create(f._10y.clone(), n._10y.id),
_12y: create(f._12y.clone(), n._12y.id),
_15y: create(f._15y.clone(), n._15y.id),
}
}
pub fn try_new<F, E>(mut create: F) -> Result<Self, E>
where
F: FnMut(Filter, &'static str) -> Result<T, E>,
{
let f = MAX_AGE_FILTERS;
let n = MAX_AGE_NAMES;
Ok(Self {
_1w: create(f._1w.clone(), n._1w.id)?,
_1m: create(f._1m.clone(), n._1m.id)?,
_2m: create(f._2m.clone(), n._2m.id)?,
_3m: create(f._3m.clone(), n._3m.id)?,
_4m: create(f._4m.clone(), n._4m.id)?,
_5m: create(f._5m.clone(), n._5m.id)?,
_6m: create(f._6m.clone(), n._6m.id)?,
_1y: create(f._1y.clone(), n._1y.id)?,
_2y: create(f._2y.clone(), n._2y.id)?,
_3y: create(f._3y.clone(), n._3y.id)?,
_4y: create(f._4y.clone(), n._4y.id)?,
_5y: create(f._5y.clone(), n._5y.id)?,
_6y: create(f._6y.clone(), n._6y.id)?,
_7y: create(f._7y.clone(), n._7y.id)?,
_8y: create(f._8y.clone(), n._8y.id)?,
_10y: create(f._10y.clone(), n._10y.id)?,
_12y: create(f._12y.clone(), n._12y.id)?,
_15y: create(f._15y.clone(), n._15y.id)?,
})
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
[
&self._1w, &self._1m, &self._2m, &self._3m, &self._4m, &self._5m, &self._6m, &self._1y,
&self._2y, &self._3y, &self._4y, &self._5y, &self._6y, &self._7y, &self._8y,
&self._10y, &self._12y, &self._15y,
]
.into_iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
[
&mut self._1w,
&mut self._1m,
&mut self._2m,
&mut self._3m,
&mut self._4m,
&mut self._5m,
&mut self._6m,
&mut self._1y,
&mut self._2y,
&mut self._3y,
&mut self._4y,
&mut self._5y,
&mut self._6y,
&mut self._7y,
&mut self._8y,
&mut self._10y,
&mut self._12y,
&mut self._15y,
]
.into_iter()
}
pub fn par_iter_mut(&mut self) -> impl ParallelIterator<Item = &mut T>
where
T: Send + Sync,
{
[
&mut self._1w,
&mut self._1m,
&mut self._2m,
&mut self._3m,
&mut self._4m,
&mut self._5m,
&mut self._6m,
&mut self._1y,
&mut self._2y,
&mut self._3y,
&mut self._4y,
&mut self._5y,
&mut self._6y,
&mut self._7y,
&mut self._8y,
&mut self._10y,
&mut self._12y,
&mut self._15y,
]
.into_par_iter()
}
}
+221
View File
@@ -0,0 +1,221 @@
use brk_traversable::Traversable;
use rayon::prelude::*;
use serde::Serialize;
use super::{
CohortName, Filter, TimeFilter, HOURS_10Y, HOURS_12Y, HOURS_1D, HOURS_1M, HOURS_1W, HOURS_1Y,
HOURS_2M, HOURS_2Y, HOURS_3M, HOURS_3Y, HOURS_4M, HOURS_4Y, HOURS_5M, HOURS_5Y, HOURS_6M,
HOURS_6Y, HOURS_7Y, HOURS_8Y,
};
/// Min age thresholds in hours
pub const MIN_AGE_HOURS: ByMinAge<usize> = ByMinAge {
_1d: HOURS_1D,
_1w: HOURS_1W,
_1m: HOURS_1M,
_2m: HOURS_2M,
_3m: HOURS_3M,
_4m: HOURS_4M,
_5m: HOURS_5M,
_6m: HOURS_6M,
_1y: HOURS_1Y,
_2y: HOURS_2Y,
_3y: HOURS_3Y,
_4y: HOURS_4Y,
_5y: HOURS_5Y,
_6y: HOURS_6Y,
_7y: HOURS_7Y,
_8y: HOURS_8Y,
_10y: HOURS_10Y,
_12y: HOURS_12Y,
};
/// Min age filters (GreaterOrEqual threshold in hours)
pub const MIN_AGE_FILTERS: ByMinAge<Filter> = ByMinAge {
_1d: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._1d)),
_1w: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._1w)),
_1m: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._1m)),
_2m: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._2m)),
_3m: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._3m)),
_4m: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._4m)),
_5m: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._5m)),
_6m: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._6m)),
_1y: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._1y)),
_2y: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._2y)),
_3y: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._3y)),
_4y: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._4y)),
_5y: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._5y)),
_6y: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._6y)),
_7y: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._7y)),
_8y: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._8y)),
_10y: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._10y)),
_12y: Filter::Time(TimeFilter::GreaterOrEqual(MIN_AGE_HOURS._12y)),
};
/// Min age names
pub const MIN_AGE_NAMES: ByMinAge<CohortName> = ByMinAge {
_1d: CohortName::new("over_1d_old", "1d+", "Over 1 Day Old"),
_1w: CohortName::new("over_1w_old", "1w+", "Over 1 Week Old"),
_1m: CohortName::new("over_1m_old", "1m+", "Over 1 Month Old"),
_2m: CohortName::new("over_2m_old", "2m+", "Over 2 Months Old"),
_3m: CohortName::new("over_3m_old", "3m+", "Over 3 Months Old"),
_4m: CohortName::new("over_4m_old", "4m+", "Over 4 Months Old"),
_5m: CohortName::new("over_5m_old", "5m+", "Over 5 Months Old"),
_6m: CohortName::new("over_6m_old", "6m+", "Over 6 Months Old"),
_1y: CohortName::new("over_1y_old", "1y+", "Over 1 Year Old"),
_2y: CohortName::new("over_2y_old", "2y+", "Over 2 Years Old"),
_3y: CohortName::new("over_3y_old", "3y+", "Over 3 Years Old"),
_4y: CohortName::new("over_4y_old", "4y+", "Over 4 Years Old"),
_5y: CohortName::new("over_5y_old", "5y+", "Over 5 Years Old"),
_6y: CohortName::new("over_6y_old", "6y+", "Over 6 Years Old"),
_7y: CohortName::new("over_7y_old", "7y+", "Over 7 Years Old"),
_8y: CohortName::new("over_8y_old", "8y+", "Over 8 Years Old"),
_10y: CohortName::new("over_10y_old", "10y+", "Over 10 Years Old"),
_12y: CohortName::new("over_12y_old", "12y+", "Over 12 Years Old"),
};
#[derive(Default, Clone, Traversable, Serialize)]
pub struct ByMinAge<T> {
pub _1d: T,
pub _1w: T,
pub _1m: T,
pub _2m: T,
pub _3m: T,
pub _4m: T,
pub _5m: T,
pub _6m: T,
pub _1y: T,
pub _2y: T,
pub _3y: T,
pub _4y: T,
pub _5y: T,
pub _6y: T,
pub _7y: T,
pub _8y: T,
pub _10y: T,
pub _12y: T,
}
impl ByMinAge<CohortName> {
pub const fn names() -> &'static Self {
&MIN_AGE_NAMES
}
}
impl<T> ByMinAge<T> {
pub fn new<F>(mut create: F) -> Self
where
F: FnMut(Filter, &'static str) -> T,
{
let f = MIN_AGE_FILTERS;
let n = MIN_AGE_NAMES;
Self {
_1d: create(f._1d.clone(), n._1d.id),
_1w: create(f._1w.clone(), n._1w.id),
_1m: create(f._1m.clone(), n._1m.id),
_2m: create(f._2m.clone(), n._2m.id),
_3m: create(f._3m.clone(), n._3m.id),
_4m: create(f._4m.clone(), n._4m.id),
_5m: create(f._5m.clone(), n._5m.id),
_6m: create(f._6m.clone(), n._6m.id),
_1y: create(f._1y.clone(), n._1y.id),
_2y: create(f._2y.clone(), n._2y.id),
_3y: create(f._3y.clone(), n._3y.id),
_4y: create(f._4y.clone(), n._4y.id),
_5y: create(f._5y.clone(), n._5y.id),
_6y: create(f._6y.clone(), n._6y.id),
_7y: create(f._7y.clone(), n._7y.id),
_8y: create(f._8y.clone(), n._8y.id),
_10y: create(f._10y.clone(), n._10y.id),
_12y: create(f._12y.clone(), n._12y.id),
}
}
pub fn try_new<F, E>(mut create: F) -> Result<Self, E>
where
F: FnMut(Filter, &'static str) -> Result<T, E>,
{
let f = MIN_AGE_FILTERS;
let n = MIN_AGE_NAMES;
Ok(Self {
_1d: create(f._1d.clone(), n._1d.id)?,
_1w: create(f._1w.clone(), n._1w.id)?,
_1m: create(f._1m.clone(), n._1m.id)?,
_2m: create(f._2m.clone(), n._2m.id)?,
_3m: create(f._3m.clone(), n._3m.id)?,
_4m: create(f._4m.clone(), n._4m.id)?,
_5m: create(f._5m.clone(), n._5m.id)?,
_6m: create(f._6m.clone(), n._6m.id)?,
_1y: create(f._1y.clone(), n._1y.id)?,
_2y: create(f._2y.clone(), n._2y.id)?,
_3y: create(f._3y.clone(), n._3y.id)?,
_4y: create(f._4y.clone(), n._4y.id)?,
_5y: create(f._5y.clone(), n._5y.id)?,
_6y: create(f._6y.clone(), n._6y.id)?,
_7y: create(f._7y.clone(), n._7y.id)?,
_8y: create(f._8y.clone(), n._8y.id)?,
_10y: create(f._10y.clone(), n._10y.id)?,
_12y: create(f._12y.clone(), n._12y.id)?,
})
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
[
&self._1d, &self._1w, &self._1m, &self._2m, &self._3m, &self._4m, &self._5m, &self._6m,
&self._1y, &self._2y, &self._3y, &self._4y, &self._5y, &self._6y, &self._7y, &self._8y,
&self._10y, &self._12y,
]
.into_iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
[
&mut self._1d,
&mut self._1w,
&mut self._1m,
&mut self._2m,
&mut self._3m,
&mut self._4m,
&mut self._5m,
&mut self._6m,
&mut self._1y,
&mut self._2y,
&mut self._3y,
&mut self._4y,
&mut self._5y,
&mut self._6y,
&mut self._7y,
&mut self._8y,
&mut self._10y,
&mut self._12y,
]
.into_iter()
}
pub fn par_iter_mut(&mut self) -> impl ParallelIterator<Item = &mut T>
where
T: Send + Sync,
{
[
&mut self._1d,
&mut self._1w,
&mut self._1m,
&mut self._2m,
&mut self._3m,
&mut self._4m,
&mut self._5m,
&mut self._6m,
&mut self._1y,
&mut self._2y,
&mut self._3y,
&mut self._4y,
&mut self._5y,
&mut self._6y,
&mut self._7y,
&mut self._8y,
&mut self._10y,
&mut self._12y,
]
.into_par_iter()
}
}
+264
View File
@@ -0,0 +1,264 @@
use std::ops::{Add, AddAssign};
use brk_traversable::Traversable;
use brk_types::OutputType;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use serde::Serialize;
use super::{CohortName, Filter};
/// Spendable type values
pub const SPENDABLE_TYPE_VALUES: BySpendableType<OutputType> = BySpendableType {
p2pk65: OutputType::P2PK65,
p2pk33: OutputType::P2PK33,
p2pkh: OutputType::P2PKH,
p2ms: OutputType::P2MS,
p2sh: OutputType::P2SH,
p2wpkh: OutputType::P2WPKH,
p2wsh: OutputType::P2WSH,
p2tr: OutputType::P2TR,
p2a: OutputType::P2A,
unknown: OutputType::Unknown,
empty: OutputType::Empty,
};
/// Spendable type filters
pub const SPENDABLE_TYPE_FILTERS: BySpendableType<Filter> = BySpendableType {
p2pk65: Filter::Type(SPENDABLE_TYPE_VALUES.p2pk65),
p2pk33: Filter::Type(SPENDABLE_TYPE_VALUES.p2pk33),
p2pkh: Filter::Type(SPENDABLE_TYPE_VALUES.p2pkh),
p2ms: Filter::Type(SPENDABLE_TYPE_VALUES.p2ms),
p2sh: Filter::Type(SPENDABLE_TYPE_VALUES.p2sh),
p2wpkh: Filter::Type(SPENDABLE_TYPE_VALUES.p2wpkh),
p2wsh: Filter::Type(SPENDABLE_TYPE_VALUES.p2wsh),
p2tr: Filter::Type(SPENDABLE_TYPE_VALUES.p2tr),
p2a: Filter::Type(SPENDABLE_TYPE_VALUES.p2a),
unknown: Filter::Type(SPENDABLE_TYPE_VALUES.unknown),
empty: Filter::Type(SPENDABLE_TYPE_VALUES.empty),
};
/// Spendable type names
pub const SPENDABLE_TYPE_NAMES: BySpendableType<CohortName> = BySpendableType {
p2pk65: CohortName::new("p2pk65", "P2PK65", "Pay to Public Key (65 bytes)"),
p2pk33: CohortName::new("p2pk33", "P2PK33", "Pay to Public Key (33 bytes)"),
p2pkh: CohortName::new("p2pkh", "P2PKH", "Pay to Public Key Hash"),
p2ms: CohortName::new("p2ms", "P2MS", "Pay to Multisig"),
p2sh: CohortName::new("p2sh", "P2SH", "Pay to Script Hash"),
p2wpkh: CohortName::new("p2wpkh", "P2WPKH", "Pay to Witness Public Key Hash"),
p2wsh: CohortName::new("p2wsh", "P2WSH", "Pay to Witness Script Hash"),
p2tr: CohortName::new("p2tr", "P2TR", "Pay to Taproot"),
p2a: CohortName::new("p2a", "P2A", "Pay to Anchor"),
unknown: CohortName::new("unknown_outputs", "Unknown", "Unknown Output Type"),
empty: CohortName::new("empty_outputs", "Empty", "Empty Output"),
};
#[derive(Default, Clone, Debug, Traversable, Serialize)]
pub struct BySpendableType<T> {
pub p2pk65: T,
pub p2pk33: T,
pub p2pkh: T,
pub p2ms: T,
pub p2sh: T,
pub p2wpkh: T,
pub p2wsh: T,
pub p2tr: T,
pub p2a: T,
pub unknown: T,
pub empty: T,
}
impl BySpendableType<CohortName> {
pub const fn names() -> &'static Self {
&SPENDABLE_TYPE_NAMES
}
}
impl<T> BySpendableType<T> {
pub fn new<F>(mut create: F) -> Self
where
F: FnMut(Filter, &'static str) -> T,
{
let f = SPENDABLE_TYPE_FILTERS;
let n = SPENDABLE_TYPE_NAMES;
Self {
p2pk65: create(f.p2pk65, n.p2pk65.id),
p2pk33: create(f.p2pk33, n.p2pk33.id),
p2pkh: create(f.p2pkh, n.p2pkh.id),
p2ms: create(f.p2ms, n.p2ms.id),
p2sh: create(f.p2sh, n.p2sh.id),
p2wpkh: create(f.p2wpkh, n.p2wpkh.id),
p2wsh: create(f.p2wsh, n.p2wsh.id),
p2tr: create(f.p2tr, n.p2tr.id),
p2a: create(f.p2a, n.p2a.id),
unknown: create(f.unknown, n.unknown.id),
empty: create(f.empty, n.empty.id),
}
}
pub fn try_new<F, E>(mut create: F) -> Result<Self, E>
where
F: FnMut(Filter, &'static str) -> Result<T, E>,
{
let f = SPENDABLE_TYPE_FILTERS;
let n = SPENDABLE_TYPE_NAMES;
Ok(Self {
p2pk65: create(f.p2pk65, n.p2pk65.id)?,
p2pk33: create(f.p2pk33, n.p2pk33.id)?,
p2pkh: create(f.p2pkh, n.p2pkh.id)?,
p2ms: create(f.p2ms, n.p2ms.id)?,
p2sh: create(f.p2sh, n.p2sh.id)?,
p2wpkh: create(f.p2wpkh, n.p2wpkh.id)?,
p2wsh: create(f.p2wsh, n.p2wsh.id)?,
p2tr: create(f.p2tr, n.p2tr.id)?,
p2a: create(f.p2a, n.p2a.id)?,
unknown: create(f.unknown, n.unknown.id)?,
empty: create(f.empty, n.empty.id)?,
})
}
pub fn get_mut(&mut self, output_type: OutputType) -> &mut T {
match output_type {
OutputType::P2PK65 => &mut self.p2pk65,
OutputType::P2PK33 => &mut self.p2pk33,
OutputType::P2PKH => &mut self.p2pkh,
OutputType::P2MS => &mut self.p2ms,
OutputType::P2SH => &mut self.p2sh,
OutputType::P2WPKH => &mut self.p2wpkh,
OutputType::P2WSH => &mut self.p2wsh,
OutputType::P2TR => &mut self.p2tr,
OutputType::P2A => &mut self.p2a,
OutputType::Unknown => &mut self.unknown,
OutputType::Empty => &mut self.empty,
_ => unreachable!(),
}
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
[
&self.p2pk65,
&self.p2pk33,
&self.p2pkh,
&self.p2ms,
&self.p2sh,
&self.p2wpkh,
&self.p2wsh,
&self.p2tr,
&self.p2a,
&self.unknown,
&self.empty,
]
.into_iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
[
&mut self.p2pk65,
&mut self.p2pk33,
&mut self.p2pkh,
&mut self.p2ms,
&mut self.p2sh,
&mut self.p2wpkh,
&mut self.p2wsh,
&mut self.p2tr,
&mut self.p2a,
&mut self.unknown,
&mut self.empty,
]
.into_iter()
}
pub fn par_iter_mut(&mut self) -> impl ParallelIterator<Item = &mut T>
where
T: Send + Sync,
{
[
&mut self.p2pk65,
&mut self.p2pk33,
&mut self.p2pkh,
&mut self.p2ms,
&mut self.p2sh,
&mut self.p2wpkh,
&mut self.p2wsh,
&mut self.p2tr,
&mut self.p2a,
&mut self.unknown,
&mut self.empty,
]
.into_par_iter()
}
pub fn iter_typed(&self) -> impl Iterator<Item = (OutputType, &T)> {
[
(OutputType::P2PK65, &self.p2pk65),
(OutputType::P2PK33, &self.p2pk33),
(OutputType::P2PKH, &self.p2pkh),
(OutputType::P2MS, &self.p2ms),
(OutputType::P2SH, &self.p2sh),
(OutputType::P2WPKH, &self.p2wpkh),
(OutputType::P2WSH, &self.p2wsh),
(OutputType::P2TR, &self.p2tr),
(OutputType::P2A, &self.p2a),
(OutputType::Unknown, &self.unknown),
(OutputType::Empty, &self.empty),
]
.into_iter()
}
pub fn iter_typed_mut(&mut self) -> impl Iterator<Item = (OutputType, &mut T)> {
[
(OutputType::P2PK65, &mut self.p2pk65),
(OutputType::P2PK33, &mut self.p2pk33),
(OutputType::P2PKH, &mut self.p2pkh),
(OutputType::P2MS, &mut self.p2ms),
(OutputType::P2SH, &mut self.p2sh),
(OutputType::P2WPKH, &mut self.p2wpkh),
(OutputType::P2WSH, &mut self.p2wsh),
(OutputType::P2TR, &mut self.p2tr),
(OutputType::P2A, &mut self.p2a),
(OutputType::Unknown, &mut self.unknown),
(OutputType::Empty, &mut self.empty),
]
.into_iter()
}
}
impl<T> Add for BySpendableType<T>
where
T: Add<Output = T>,
{
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self {
p2pk65: self.p2pk65 + rhs.p2pk65,
p2pk33: self.p2pk33 + rhs.p2pk33,
p2pkh: self.p2pkh + rhs.p2pkh,
p2ms: self.p2ms + rhs.p2ms,
p2sh: self.p2sh + rhs.p2sh,
p2wpkh: self.p2wpkh + rhs.p2wpkh,
p2wsh: self.p2wsh + rhs.p2wsh,
p2tr: self.p2tr + rhs.p2tr,
p2a: self.p2a + rhs.p2a,
unknown: self.unknown + rhs.unknown,
empty: self.empty + rhs.empty,
}
}
}
impl<T> AddAssign for BySpendableType<T>
where
T: AddAssign,
{
fn add_assign(&mut self, rhs: Self) {
self.p2pk65 += rhs.p2pk65;
self.p2pk33 += rhs.p2pk33;
self.p2pkh += rhs.p2pkh;
self.p2ms += rhs.p2ms;
self.p2sh += rhs.p2sh;
self.p2wpkh += rhs.p2wpkh;
self.p2wsh += rhs.p2wsh;
self.p2tr += rhs.p2tr;
self.p2a += rhs.p2a;
self.unknown += rhs.unknown;
self.empty += rhs.empty;
}
}
+83
View File
@@ -0,0 +1,83 @@
use brk_traversable::Traversable;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use serde::Serialize;
use super::{CohortName, Filter, Term};
/// Term values
pub const TERM_VALUES: ByTerm<Term> = ByTerm {
short: Term::Sth,
long: Term::Lth,
};
/// Term filters
pub const TERM_FILTERS: ByTerm<Filter> = ByTerm {
short: Filter::Term(TERM_VALUES.short),
long: Filter::Term(TERM_VALUES.long),
};
/// Term names
pub const TERM_NAMES: ByTerm<CohortName> = ByTerm {
short: CohortName::new("sth", "STH", "Short Term Holders"),
long: CohortName::new("lth", "LTH", "Long Term Holders"),
};
#[derive(Default, Clone, Traversable, Serialize)]
pub struct ByTerm<T> {
pub short: T,
pub long: T,
}
impl ByTerm<CohortName> {
pub const fn names() -> &'static Self {
&TERM_NAMES
}
}
impl<T> ByTerm<T> {
pub fn new<F>(mut create: F) -> Self
where
F: FnMut(Filter, &'static str) -> T,
{
let f = TERM_FILTERS;
let n = TERM_NAMES;
Self {
short: create(f.short, n.short.id),
long: create(f.long, n.long.id),
}
}
pub fn try_new<F, E>(mut create: F) -> Result<Self, E>
where
F: FnMut(Filter, &'static str) -> Result<T, E>,
{
let f = TERM_FILTERS;
let n = TERM_NAMES;
Ok(Self {
short: create(f.short, n.short.id)?,
long: create(f.long, n.long.id)?,
})
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
[&self.short, &self.long].into_iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
[&mut self.short, &mut self.long].into_iter()
}
pub fn par_iter(&self) -> impl ParallelIterator<Item = &T>
where
T: Send + Sync,
{
[&self.short, &self.long].into_par_iter()
}
pub fn par_iter_mut(&mut self) -> impl ParallelIterator<Item = &mut T>
where
T: Send + Sync,
{
[&mut self.short, &mut self.long].into_par_iter()
}
}

Some files were not shown because too many files have changed in this diff Show More