@@ -18,15 +18,164 @@ use numpy::{
1818} ;
1919use pyo3:: exceptions:: { PyIOError , PyRuntimeError , PyValueError } ;
2020use pyo3:: prelude:: * ;
21- use pyo3:: types:: PyType ;
21+ use pyo3:: types:: { PyDict , PyType } ;
2222
23+ use std:: collections:: HashMap ;
2324use std:: fs:: File ;
2425use std:: io:: { BufReader , Read } ;
2526
2627/// PQ subspace count. Hard-coded to match the rest of the codebase; the public
2728/// `pq_subspaces` kwarg is validated against this value (warns if different).
2829const M_FIXED : usize = 32 ;
2930
31+ /// Minimum points per centroid — mirrors the TAC allocation strategy constant.
32+ const MIN_PTS_PER_CENTROID : usize = 39 ;
33+ /// Safety factor applied to the minimum TAC budget when computing the floor.
34+ const TAC_FLOOR_FACTOR : f64 = 1.1 ;
35+
36+ // ============================================================================
37+ // Auto build-param heuristic
38+ // ============================================================================
39+
40+ /// Resolved TAC build parameters, returned by [`resolve_tac_params`].
41+ struct ResolvedTacParams {
42+ total_centroids : usize ,
43+ micro_threshold : usize ,
44+ small_threshold : usize ,
45+ /// Saturation cap: adding more centroids beyond this is pointless.
46+ sat_cap : usize ,
47+ /// Raw power-of-2 formula value, before floor / cap.
48+ formula : usize ,
49+ /// True when an explicit `total_centroids` value was capped to `sat_cap`.
50+ was_capped : bool ,
51+ }
52+
53+ /// Compute recommended TAC build parameters from the token-ID distribution.
54+ ///
55+ /// All `_override` arguments mirror the user-facing kwargs: `None` → auto-compute.
56+ fn resolve_tac_params (
57+ token_ids : & [ u32 ] ,
58+ total_centroids_override : Option < usize > ,
59+ micro_override : Option < usize > ,
60+ small_override : Option < usize > ,
61+ ) -> ResolvedTacParams {
62+ let n_tokens = token_ids. len ( ) . max ( 128 ) ;
63+
64+ // Nearest power-of-2 to n_tokens / 128.
65+ let exp = ( n_tokens as f64 / 128.0 ) . log2 ( ) . round ( ) as u32 ;
66+ let formula = 1usize << exp;
67+
68+ // Thresholds: nearest power-of-2 to n_tokens^(1/4), clamped to [32, 128].
69+ let micro_exp = ( n_tokens as f64 ) . powf ( 0.25 ) . log2 ( ) . round ( ) as u32 ;
70+ let micro_auto = ( 1usize << micro_exp) . clamp ( 32 , 128 ) ;
71+ let small_auto = micro_auto * 2 ;
72+ let micro = micro_override. unwrap_or ( micro_auto) ;
73+ let small = small_override. unwrap_or ( small_auto) ;
74+
75+ // Token-type frequency histogram.
76+ let mut freq: HashMap < u32 , usize > = HashMap :: new ( ) ;
77+ for & id in token_ids {
78+ * freq. entry ( id) . or_insert ( 0 ) += 1 ;
79+ }
80+
81+ let mut n_micro_t: usize = 0 ;
82+ let mut n_small_t: usize = 0 ;
83+ let mut n_active_t: usize = 0 ;
84+ let mut total_active_tokens: usize = 0 ;
85+ for & c in freq. values ( ) {
86+ if c < micro {
87+ n_micro_t += 1 ;
88+ } else if c < small {
89+ n_small_t += 1 ;
90+ } else {
91+ n_active_t += 1 ;
92+ total_active_tokens += c;
93+ }
94+ }
95+
96+ let min_budget = n_micro_t + n_small_t * 2 + n_active_t * 4 ;
97+ let sat_cap = n_micro_t + n_small_t * 2 + total_active_tokens / MIN_PTS_PER_CENTROID ;
98+
99+ // TAC floor: enough to run TAC with buffer, but never above sat_cap.
100+ let tac_floor = ( ( min_budget as f64 * TAC_FLOOR_FACTOR ) . ceil ( ) as usize ) . min ( sat_cap) ;
101+
102+ let ( total_centroids, was_capped) = match total_centroids_override {
103+ Some ( tc) if tc > sat_cap => ( sat_cap, true ) ,
104+ Some ( tc) => ( tc, false ) ,
105+ None => ( formula. max ( tac_floor) , false ) ,
106+ } ;
107+
108+ ResolvedTacParams {
109+ total_centroids,
110+ micro_threshold : micro,
111+ small_threshold : small,
112+ sat_cap,
113+ formula,
114+ was_capped,
115+ }
116+ }
117+
118+ // ============================================================================
119+ // Python-exposed auto_build_params function
120+ // ============================================================================
121+
122+ /// Compute recommended TAC build parameters from a flat token-ID array.
123+ ///
124+ /// Returns a dict with keys ``total_centroids``, ``tac_micro_threshold``,
125+ /// ``tac_small_threshold``, ``sat_cap``, and ``formula``.
126+ ///
127+ /// Any kwarg set to a non-``None`` value overrides the heuristic for that
128+ /// parameter; ``None`` (default) triggers full auto-computation.
129+ ///
130+ /// A ``UserWarning`` is emitted when an explicit ``total_centroids`` exceeds
131+ /// the saturation cap.
132+ #[ pyfunction]
133+ #[ pyo3( signature = (
134+ token_ids,
135+ * ,
136+ total_centroids = None ,
137+ tac_micro_threshold = None ,
138+ tac_small_threshold = None ,
139+ ) ) ]
140+ fn auto_build_params (
141+ py : Python < ' _ > ,
142+ token_ids : PyReadonlyArray1 < ' _ , u32 > ,
143+ total_centroids : Option < usize > ,
144+ tac_micro_threshold : Option < usize > ,
145+ tac_small_threshold : Option < usize > ,
146+ ) -> PyResult < Py < PyDict > > {
147+ let ids = token_ids
148+ . as_slice ( )
149+ . map_err ( |_| PyValueError :: new_err ( "token_ids must be C-contiguous" ) ) ?;
150+
151+ let p = resolve_tac_params (
152+ ids,
153+ total_centroids,
154+ tac_micro_threshold,
155+ tac_small_threshold,
156+ ) ;
157+
158+ if p. was_capped {
159+ let msg = format ! (
160+ "total_centroids={} exceeds the saturation point ({}). \
161+ Capping to {}. Extra centroids beyond this point would be empty.",
162+ total_centroids. unwrap( ) ,
163+ p. sat_cap,
164+ p. sat_cap,
165+ ) ;
166+ let warnings = py. import ( "warnings" ) ?;
167+ warnings. call_method1 ( "warn" , ( msg, ) ) ?;
168+ }
169+
170+ let dict = PyDict :: new ( py) ;
171+ dict. set_item ( "total_centroids" , p. total_centroids ) ?;
172+ dict. set_item ( "tac_micro_threshold" , p. micro_threshold ) ?;
173+ dict. set_item ( "tac_small_threshold" , p. small_threshold ) ?;
174+ dict. set_item ( "sat_cap" , p. sat_cap ) ?;
175+ dict. set_item ( "formula" , p. formula ) ?;
176+ Ok ( dict. into ( ) )
177+ }
178+
30179// ============================================================================
31180// Module
32181// ============================================================================
@@ -35,6 +184,7 @@ const M_FIXED: usize = 32;
35184fn tachiom ( m : & Bound < ' _ , PyModule > ) -> PyResult < ( ) > {
36185 m. add_class :: < PyTachiom > ( ) ?;
37186 m. add_class :: < PyTac > ( ) ?;
187+ m. add_function ( wrap_pyfunction ! ( auto_build_params, m) ?) ?;
38188 Ok ( ( ) )
39189}
40190
@@ -58,13 +208,13 @@ impl PyTachiom {
58208 token_ids_path,
59209 doclens_path,
60210 * ,
61- total_centroids = 4_194_304 ,
211+ total_centroids = None ,
62212 tac_n_iter = 10 ,
63213 tac_micro_threshold = None ,
64214 tac_small_threshold = None ,
65215 pq_sample_size = 10_000_000 ,
66216 pq_n_iter = 10 ,
67- normalize = false ,
217+ normalize = true ,
68218 pq_seed = 42 ,
69219 hnsw_m = 32 ,
70220 ef_construction = 1500 ,
@@ -77,7 +227,7 @@ impl PyTachiom {
77227 vectors_path : & str ,
78228 token_ids_path : & str ,
79229 doclens_path : & str ,
80- total_centroids : usize ,
230+ total_centroids : Option < usize > ,
81231 tac_n_iter : usize ,
82232 tac_micro_threshold : Option < usize > ,
83233 tac_small_threshold : Option < usize > ,
@@ -92,12 +242,23 @@ impl PyTachiom {
92242 warn_pq_subspaces ( py, pq_subspaces) ?;
93243 let ( dataset, token_ids) = load_input_dataset ( vectors_path, token_ids_path, doclens_path) ?;
94244
95- let params = TachiomBuildParams {
96- token_ids,
245+ let token_ids_u32: Vec < u32 > = token_ids. iter ( ) . map ( |& x| x as u32 ) . collect ( ) ;
246+ let resolved = resolve_tac_params (
247+ & token_ids_u32,
97248 total_centroids,
98- tac_n_iter,
99249 tac_micro_threshold,
100250 tac_small_threshold,
251+ ) ;
252+ if resolved. was_capped {
253+ warn_saturation_cap ( py, total_centroids. unwrap ( ) , resolved. sat_cap ) ?;
254+ }
255+
256+ let params = TachiomBuildParams {
257+ token_ids,
258+ total_centroids : resolved. total_centroids ,
259+ tac_n_iter,
260+ tac_micro_threshold : Some ( resolved. micro_threshold ) ,
261+ tac_small_threshold : Some ( resolved. small_threshold ) ,
101262 pq_sample_size,
102263 pq_n_iter,
103264 normalize,
@@ -124,13 +285,13 @@ impl PyTachiom {
124285 token_ids,
125286 doclens,
126287 * ,
127- total_centroids = 4_194_304 ,
288+ total_centroids = None ,
128289 tac_n_iter = 10 ,
129290 tac_micro_threshold = None ,
130291 tac_small_threshold = None ,
131292 pq_sample_size = 10_000_000 ,
132293 pq_n_iter = 10 ,
133- normalize = false ,
294+ normalize = true ,
134295 pq_seed = 42 ,
135296 hnsw_m = 32 ,
136297 ef_construction = 1500 ,
@@ -143,7 +304,7 @@ impl PyTachiom {
143304 vectors : PyReadonlyArray2 < ' _ , u16 > ,
144305 token_ids : PyReadonlyArray1 < ' _ , u32 > ,
145306 doclens : PyReadonlyArray1 < ' _ , i32 > ,
146- total_centroids : usize ,
307+ total_centroids : Option < usize > ,
147308 tac_n_iter : usize ,
148309 tac_micro_threshold : Option < usize > ,
149310 tac_small_threshold : Option < usize > ,
@@ -158,12 +319,25 @@ impl PyTachiom {
158319 warn_pq_subspaces ( py, pq_subspaces) ?;
159320 let ( dataset, token_ids_vec) = dataset_from_arrays ( & vectors, & token_ids, & doclens) ?;
160321
161- let params = TachiomBuildParams {
162- token_ids : token_ids_vec,
322+ let ids_u32 = token_ids
323+ . as_slice ( )
324+ . map_err ( |_| PyValueError :: new_err ( "token_ids must be C-contiguous" ) ) ?;
325+ let resolved = resolve_tac_params (
326+ ids_u32,
163327 total_centroids,
164- tac_n_iter,
165328 tac_micro_threshold,
166329 tac_small_threshold,
330+ ) ;
331+ if resolved. was_capped {
332+ warn_saturation_cap ( py, total_centroids. unwrap ( ) , resolved. sat_cap ) ?;
333+ }
334+
335+ let params = TachiomBuildParams {
336+ token_ids : token_ids_vec,
337+ total_centroids : resolved. total_centroids ,
338+ tac_n_iter,
339+ tac_micro_threshold : Some ( resolved. micro_threshold ) ,
340+ tac_small_threshold : Some ( resolved. small_threshold ) ,
167341 pq_sample_size,
168342 pq_n_iter,
169343 normalize,
@@ -704,6 +878,16 @@ impl PyTac {
704878// Helpers
705879// ============================================================================
706880
881+ fn warn_saturation_cap ( py : Python < ' _ > , requested : usize , sat_cap : usize ) -> PyResult < ( ) > {
882+ let msg = format ! (
883+ "total_centroids={requested} exceeds the saturation point ({sat_cap}). \
884+ Capping to {sat_cap}. Extra centroids beyond this point would be empty."
885+ ) ;
886+ let warnings = py. import ( "warnings" ) ?;
887+ warnings. call_method1 ( "warn" , ( msg, ) ) ?;
888+ Ok ( ( ) )
889+ }
890+
707891fn warn_pq_subspaces ( py : Python < ' _ > , pq_subspaces : usize ) -> PyResult < ( ) > {
708892 if pq_subspaces != M_FIXED {
709893 let msg = format ! (
0 commit comments