bigdata-smart-batching 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1593 @@
1
+ """
2
+ Smart Batching for Semantic Search
3
+
4
+ This module implements a planning system that uses the comention endpoint to determine
5
+ chunk volumes per company, then creates optimized baskets of companies for semantic
6
+ search queries across multiple time periods with adaptive granularity.
7
+
8
+ The system uses a two-phase approach:
9
+ 1. Phase 1: Query the full time period once to get total chunk volumes for all companies
10
+ 2. Phase 2: Automatically determine optimal time granularity for each company based on
11
+ volume and create baskets that minimize the total number of semantic search queries.
12
+ """
13
+
14
+ import csv
15
+ import json
16
+ import math
17
+ import os
18
+ from collections import defaultdict
19
+ from concurrent.futures import ThreadPoolExecutor, as_completed
20
+ from datetime import datetime, timedelta
21
+ from typing import TypedDict
22
+
23
+ import requests
24
+
25
+ from .smart_batching_config import (
26
+ API_BASE_URL,
27
+ COMENTION_ENDPOINT,
28
+ END_DATE,
29
+ MAX_CHUNKS_PER_BASKET,
30
+ MAX_ENTITIES_IN_ANY_OF,
31
+ MAX_ENTITIES_PER_QUERY,
32
+ START_DATE,
33
+ UNIVERSE_CSV_PATH,
34
+ VOLUME_BUCKETS,
35
+ VOLUME_ENDPOINT,
36
+ )
37
+
38
+
39
+ class VolumePoint(TypedDict, total=False):
40
+ """One point in a volume time series from the volume endpoint."""
41
+
42
+ date: str
43
+ documents: int
44
+ chunks: int
45
+ sentiment: float
46
+
47
+
48
+ class SmartBatchingPlanner:
49
+ """
50
+ Main orchestrator class for smart batching planning.
51
+
52
+ This class handles the complete workflow of:
53
+ - Loading company universes from CSV
54
+ - Querying comention volumes via the Bigdata API
55
+ - Creating optimized baskets of companies for semantic search
56
+ - Determining optimal time granularity per company
57
+ - Exporting planning results to CSV files
58
+ """
59
+
60
+ def __init__(self, api_key: str | None = None, api_base_url: str | None = None):
61
+ """
62
+ Initialize the planner.
63
+
64
+ Args:
65
+ api_key: BigData API key. If None, will try to get from environment variable BIGDATA_API_KEY.
66
+ api_base_url: API base URL. If None, will use API_BASE_URL from config or environment variable.
67
+ """
68
+ self.api_key = api_key or os.getenv("BIGDATA_API_KEY")
69
+ if not self.api_key:
70
+ raise ValueError(
71
+ "API key must be provided or set in BIGDATA_API_KEY environment variable"
72
+ )
73
+
74
+ # Use provided api_base_url, or fall back to config/environment
75
+ self.api_base_url = api_base_url or os.getenv("BIGDATA_API_BASE_URL") or API_BASE_URL
76
+ self.api_url = f"{self.api_base_url}{COMENTION_ENDPOINT}"
77
+ self.volume_url = f"{self.api_base_url}{VOLUME_ENDPOINT}"
78
+ self.headers = {
79
+ "X-API-KEY": self.api_key,
80
+ "Content-Type": "application/json",
81
+ }
82
+
83
+ def load_universe(self, csv_path: str = UNIVERSE_CSV_PATH, id_column: str = "id") -> list[str]:
84
+ """
85
+ Read companies from CSV file.
86
+
87
+ Supports two formats:
88
+ 1. CSV with header row containing 'id' column (e.g., id,name)
89
+ 2. Simple CSV with one entity ID per line (no header)
90
+
91
+ Args:
92
+ csv_path: Path to CSV file containing company IDs
93
+ id_column: Name of the column containing entity IDs (default: 'id')
94
+
95
+ Returns:
96
+ List of company IDs
97
+ """
98
+ companies = []
99
+ with open(csv_path, encoding="utf-8") as f:
100
+ reader = csv.reader(f)
101
+ first_row = next(reader, None)
102
+
103
+ if first_row is None:
104
+ return companies
105
+
106
+ # Check if first row is a header containing the id_column
107
+ first_row_lower = [col.strip().lower() for col in first_row]
108
+
109
+ if id_column.lower() in first_row_lower:
110
+ # CSV has header - find the index of the id column
111
+ id_idx = first_row_lower.index(id_column.lower())
112
+ for row in reader:
113
+ if row and len(row) > id_idx and row[id_idx].strip():
114
+ companies.append(row[id_idx].strip())
115
+ else:
116
+ # No header - treat first row as data (first column contains IDs)
117
+ if first_row and first_row[0].strip():
118
+ companies.append(first_row[0].strip())
119
+ for row in reader:
120
+ if row and row[0].strip():
121
+ companies.append(row[0].strip())
122
+ return companies
123
+
124
+ def get_comention_volumes(
125
+ self,
126
+ companies: list[str],
127
+ topic: str,
128
+ start_date: str,
129
+ end_date: str,
130
+ ) -> tuple[dict[str, int], int]:
131
+ """
132
+ Iteratively query comention endpoint for all companies to get chunk volumes.
133
+
134
+ Uses a three-pass approach for maximum accuracy:
135
+ 1. First pass: Query all companies in batches
136
+ 2. Second pass: Verify companies that didn't appear in first pass results
137
+ (they may have low volume that was pushed out by other high-volume entities)
138
+ 3. Third pass: Final verification for companies that still didn't appear in second pass
139
+ (ensures we catch all companies with any volume, even very low volume)
140
+
141
+ Args:
142
+ companies: List of company IDs
143
+ topic: Topic string for the comention query
144
+ start_date: Start date in ISO format (YYYY-MM-DD)
145
+ end_date: End date in ISO format (YYYY-MM-DD)
146
+
147
+ Returns:
148
+ Tuple of (company_volumes_dict, query_count) where:
149
+ - company_volumes_dict: Dict mapping company_id -> total_chunks_count
150
+ Companies with 0 chunks will not appear in the dict (confirmed 0 after 3 passes)
151
+ - query_count: Number of API queries made
152
+ """
153
+ company_volumes = {}
154
+ remaining_companies = companies.copy()
155
+ query_count = 0
156
+ total_queries_needed = (
157
+ len(companies) + MAX_ENTITIES_IN_ANY_OF - 1
158
+ ) // MAX_ENTITIES_IN_ANY_OF
159
+
160
+ # Convert dates to ISO format with timezone
161
+ start_iso = f"{start_date}T00:00:00Z"
162
+ end_iso = f"{end_date}T23:59:59Z"
163
+
164
+ print(
165
+ f" Querying {len(companies)} companies in batches of {MAX_ENTITIES_IN_ANY_OF} (estimated {total_queries_needed} queries)..."
166
+ )
167
+
168
+ # Track companies that were queried but didn't appear in results
169
+ unverified_companies = []
170
+
171
+ # FIRST PASS: Query all companies in batches
172
+ while remaining_companies:
173
+ # Take up to MAX_ENTITIES_IN_ANY_OF companies per query (API complexity limit)
174
+ batch = remaining_companies[:MAX_ENTITIES_IN_ANY_OF]
175
+
176
+ if not batch:
177
+ break
178
+
179
+ payload = {
180
+ "query": {
181
+ "auto_enrich_filters": False,
182
+ "entity_categories": ["companies"],
183
+ "text": topic,
184
+ "filters": {
185
+ "timestamp": {
186
+ "start": start_iso,
187
+ "end": end_iso,
188
+ },
189
+ "entity": {
190
+ "all_of": [],
191
+ "any_of": batch,
192
+ "none_of": [],
193
+ "search_in": "ALL",
194
+ },
195
+ },
196
+ "limit": MAX_ENTITIES_PER_QUERY,
197
+ }
198
+ }
199
+
200
+ try:
201
+ response = requests.post(self.api_url, json=payload, headers=self.headers)
202
+ response.raise_for_status()
203
+ data = response.json()
204
+ query_count += 1
205
+
206
+ # Extract company volumes from response
207
+ results = data.get("results", {})
208
+ companies_data = results.get("companies", [])
209
+
210
+ # Track which companies from our batch appeared in the response with chunk data
211
+ found_company_ids = set()
212
+ for company_data in companies_data:
213
+ company_id = company_data.get("id")
214
+ # Only consider companies that have total_chunks_count field
215
+ # Companies with only total_headlines_count are treated as not found
216
+ if "total_chunks_count" not in company_data:
217
+ continue
218
+ chunks_count = company_data["total_chunks_count"]
219
+ if company_id and company_id in batch and chunks_count > 0:
220
+ company_volumes[company_id] = chunks_count
221
+ found_company_ids.add(company_id)
222
+
223
+ # Identify companies from our batch that didn't appear in results
224
+ # These might have low volume that was pushed out by other high-volume entities
225
+ for company_id in batch:
226
+ if company_id not in found_company_ids:
227
+ unverified_companies.append(company_id)
228
+
229
+ # Show progress - count companies found from our universe batch
230
+ found_count = len(found_company_ids)
231
+ found_from_universe = [cid for cid in found_company_ids if cid in batch]
232
+ found_from_universe_count = len(found_from_universe)
233
+ print(
234
+ f" Query {query_count}/{total_queries_needed}: Found {found_from_universe_count} companies from universe batch (out of {len(batch)} input)"
235
+ )
236
+
237
+ # Remove processed companies from remaining list
238
+ remaining_companies = remaining_companies[MAX_ENTITIES_IN_ANY_OF:]
239
+
240
+ except requests.exceptions.HTTPError as e:
241
+ # Try to get error details from response
242
+ error_msg = str(e)
243
+ try:
244
+ error_details = response.json()
245
+ error_msg = f"{error_msg}\nResponse: {json.dumps(error_details, indent=2)}"
246
+ except:
247
+ try:
248
+ error_text = response.text
249
+ error_msg = f"{error_msg}\nResponse: {error_text[:500]}"
250
+ except:
251
+ pass
252
+ raise RuntimeError(f"Error querying comention endpoint: {error_msg}")
253
+ except requests.exceptions.RequestException as e:
254
+ raise RuntimeError(f"Error querying comention endpoint: {e}")
255
+
256
+ # SECOND PASS: Verify companies that didn't appear in first pass
257
+ still_unverified = []
258
+ if unverified_companies:
259
+ print(
260
+ f"\n Verification pass 1: Re-checking {len(unverified_companies)} companies from universe that didn't appear in first pass..."
261
+ )
262
+ verification_queries_needed = (
263
+ len(unverified_companies) + MAX_ENTITIES_IN_ANY_OF - 1
264
+ ) // MAX_ENTITIES_IN_ANY_OF
265
+
266
+ remaining_unverified = unverified_companies.copy()
267
+ verification_count = 0
268
+
269
+ while remaining_unverified:
270
+ # Query smaller batches for verification (can use same size or smaller)
271
+ batch = remaining_unverified[:MAX_ENTITIES_IN_ANY_OF]
272
+
273
+ if not batch:
274
+ break
275
+
276
+ payload = {
277
+ "query": {
278
+ "text": topic,
279
+ "filters": {
280
+ "timestamp": {
281
+ "start": start_iso,
282
+ "end": end_iso,
283
+ },
284
+ "entity": {
285
+ "all_of": [],
286
+ "any_of": batch,
287
+ "none_of": [],
288
+ "search_in": "ALL",
289
+ },
290
+ },
291
+ "limit": MAX_ENTITIES_PER_QUERY,
292
+ }
293
+ }
294
+
295
+ try:
296
+ response = requests.post(self.api_url, json=payload, headers=self.headers)
297
+ response.raise_for_status()
298
+ data = response.json()
299
+ query_count += 1
300
+ verification_count += 1
301
+
302
+ # Extract company volumes from response
303
+ results = data.get("results", {})
304
+ companies_data = results.get("companies", [])
305
+
306
+ # Track which companies from this batch appeared with chunk data
307
+ found_in_verification = set()
308
+ verified_count = 0
309
+ for company_data in companies_data:
310
+ company_id = company_data.get("id")
311
+ # Only consider companies that have total_chunks_count field
312
+ if "total_chunks_count" not in company_data:
313
+ continue
314
+ chunks_count = company_data["total_chunks_count"]
315
+ if company_id and company_id in batch and chunks_count > 0:
316
+ # Only update if this company was in our verification batch
317
+ company_volumes[company_id] = chunks_count
318
+ found_in_verification.add(company_id)
319
+ verified_count += 1
320
+
321
+ # Track companies that still didn't appear
322
+ for company_id in batch:
323
+ if company_id not in found_in_verification:
324
+ still_unverified.append(company_id)
325
+
326
+ # Count companies from universe found in this verification pass
327
+ found_in_this_pass = [cid for cid in found_in_verification if cid in batch]
328
+ found_from_universe_count = len(found_in_this_pass)
329
+ print(
330
+ f" Verification query {verification_count}/{verification_queries_needed}: Found {found_from_universe_count} companies from universe (out of {len(batch)} input)"
331
+ )
332
+
333
+ # Remove processed companies from remaining list
334
+ remaining_unverified = remaining_unverified[MAX_ENTITIES_IN_ANY_OF:]
335
+
336
+ except requests.exceptions.HTTPError as e:
337
+ error_msg = str(e)
338
+ try:
339
+ error_details = response.json()
340
+ error_msg = f"{error_msg}\nResponse: {json.dumps(error_details, indent=2)}"
341
+ except:
342
+ try:
343
+ error_text = response.text
344
+ error_msg = f"{error_msg}\nResponse: {error_text[:500]}"
345
+ except:
346
+ pass
347
+ raise RuntimeError(f"Error in verification query: {error_msg}")
348
+ except requests.exceptions.RequestException as e:
349
+ raise RuntimeError(f"Error in verification query: {e}")
350
+
351
+ # THIRD PASS: Final verification for companies that still didn't appear
352
+ if still_unverified:
353
+ print(
354
+ f"\n Verification pass 2: Final check for {len(still_unverified)} companies from universe that still need verification..."
355
+ )
356
+ final_verification_queries_needed = (
357
+ len(still_unverified) + MAX_ENTITIES_IN_ANY_OF - 1
358
+ ) // MAX_ENTITIES_IN_ANY_OF
359
+
360
+ remaining_final_unverified = still_unverified.copy()
361
+ final_verification_count = 0
362
+
363
+ while remaining_final_unverified:
364
+ batch = remaining_final_unverified[:MAX_ENTITIES_IN_ANY_OF]
365
+
366
+ if not batch:
367
+ break
368
+
369
+ payload = {
370
+ "query": {
371
+ "text": topic,
372
+ "filters": {
373
+ "timestamp": {
374
+ "start": start_iso,
375
+ "end": end_iso,
376
+ },
377
+ "entity": {
378
+ "all_of": [],
379
+ "any_of": batch,
380
+ "none_of": [],
381
+ "search_in": "ALL",
382
+ },
383
+ },
384
+ "limit": MAX_ENTITIES_PER_QUERY,
385
+ }
386
+ }
387
+
388
+ try:
389
+ response = requests.post(self.api_url, json=payload, headers=self.headers)
390
+ response.raise_for_status()
391
+ data = response.json()
392
+ query_count += 1
393
+ final_verification_count += 1
394
+
395
+ # Extract company volumes from response
396
+ results = data.get("results", {})
397
+ companies_data = results.get("companies", [])
398
+
399
+ final_verified_count = 0
400
+ found_in_final_pass = []
401
+ for company_data in companies_data:
402
+ company_id = company_data.get("id")
403
+ # Only consider companies that have total_chunks_count field
404
+ if "total_chunks_count" not in company_data:
405
+ continue
406
+ chunks_count = company_data["total_chunks_count"]
407
+ if company_id and company_id in batch and chunks_count > 0:
408
+ # Only update if this company was in our final verification batch
409
+ company_volumes[company_id] = chunks_count
410
+ found_in_final_pass.append(company_id)
411
+ final_verified_count += 1
412
+
413
+ # Count companies from universe found in final verification
414
+ found_from_universe_count = len(found_in_final_pass)
415
+ print(
416
+ f" Final verification query {final_verification_count}/{final_verification_queries_needed}: Found {found_from_universe_count} companies from universe (out of {len(batch)} input)"
417
+ )
418
+
419
+ # Remove processed companies from remaining list
420
+ remaining_final_unverified = remaining_final_unverified[MAX_ENTITIES_IN_ANY_OF:]
421
+
422
+ except requests.exceptions.HTTPError as e:
423
+ error_msg = str(e)
424
+ try:
425
+ error_details = response.json()
426
+ error_msg = f"{error_msg}\nResponse: {json.dumps(error_details, indent=2)}"
427
+ except:
428
+ try:
429
+ error_text = response.text
430
+ error_msg = f"{error_msg}\nResponse: {error_text[:500]}"
431
+ except:
432
+ pass
433
+ raise RuntimeError(f"Error in final verification query: {error_msg}")
434
+ except requests.exceptions.RequestException as e:
435
+ raise RuntimeError(f"Error in final verification query: {e}")
436
+
437
+ # Companies that still don't appear after third pass are confirmed to have 0 volume
438
+ confirmed_zero = len(still_unverified) - len(
439
+ [c for c in still_unverified if c in company_volumes]
440
+ )
441
+ if confirmed_zero > 0:
442
+ print(
443
+ f" Confirmed {confirmed_zero} companies with zero volume after final verification"
444
+ )
445
+ elif unverified_companies:
446
+ # If there were unverified companies but none remain after second pass, all were found
447
+ confirmed_zero = len(unverified_companies) - len(
448
+ [c for c in unverified_companies if c in company_volumes]
449
+ )
450
+ if confirmed_zero > 0:
451
+ print(
452
+ f" Confirmed {confirmed_zero} companies with zero volume after first verification pass"
453
+ )
454
+
455
+ print(
456
+ f" Completed {query_count} queries. Found {len(company_volumes)} companies with chunks > 0"
457
+ )
458
+ return company_volumes, query_count
459
+
460
+ def _process_single_batch_comention(
461
+ self,
462
+ batch_idx: int,
463
+ companies: list[str],
464
+ topic: str,
465
+ start_iso: str,
466
+ end_iso: str,
467
+ max_iterations_per_batch: int,
468
+ total_batches: int,
469
+ ) -> tuple[dict[str, int], int, list[str]]:
470
+ """
471
+ Process a single batch for comention iterative query. Used by ThreadPoolExecutor.
472
+ Returns (batch_company_volumes, batch_query_count, batch_very_low_list).
473
+ """
474
+ batch_start = batch_idx * MAX_ENTITIES_IN_ANY_OF
475
+ batch_end = min(batch_start + MAX_ENTITIES_IN_ANY_OF, len(companies))
476
+ batch_original = companies[batch_start:batch_end]
477
+ batch_original_size = len(batch_original)
478
+
479
+ batch_company_volumes: dict[str, int] = {}
480
+ batch_remaining_set = set(batch_original)
481
+ batch_found_total = 0
482
+ batch_query_count = 0
483
+ iteration = 0
484
+
485
+ while batch_remaining_set and iteration < max_iterations_per_batch:
486
+ iteration += 1
487
+ batch_remaining_list = list(batch_remaining_set)
488
+
489
+ payload = {
490
+ "query": {
491
+ "auto_enrich_filters": False,
492
+ "entity_categories": ["companies"],
493
+ "text": topic,
494
+ "filters": {
495
+ "timestamp": {"start": start_iso, "end": end_iso},
496
+ "entity": {
497
+ "all_of": [],
498
+ "any_of": batch_remaining_list,
499
+ "none_of": [],
500
+ "search_in": "ALL",
501
+ },
502
+ },
503
+ "limit": MAX_ENTITIES_PER_QUERY,
504
+ }
505
+ }
506
+
507
+ try:
508
+ response = requests.post(self.api_url, json=payload, headers=self.headers)
509
+ response.raise_for_status()
510
+ data = response.json()
511
+ batch_query_count += 1
512
+
513
+ results = data.get("results", {})
514
+ companies_data = results.get("companies", [])
515
+
516
+ found_in_iteration: set[str] = set()
517
+ for company_data in companies_data:
518
+ company_id = company_data.get("id")
519
+ if "total_chunks_count" not in company_data:
520
+ continue
521
+ chunks_count = company_data["total_chunks_count"]
522
+ if company_id and company_id in batch_remaining_set and chunks_count > 0:
523
+ batch_company_volumes[company_id] = chunks_count
524
+ found_in_iteration.add(company_id)
525
+
526
+ found_count = len(found_in_iteration)
527
+ batch_found_total += found_count
528
+ batch_remaining_set -= found_in_iteration
529
+
530
+ print(
531
+ f" Batch {batch_idx + 1}/{total_batches}, Iter {iteration}: "
532
+ f"Found {found_count} new companies, {len(batch_remaining_set)} remaining"
533
+ )
534
+
535
+ if found_count == 0:
536
+ break
537
+
538
+ except requests.exceptions.HTTPError as e:
539
+ error_msg = str(e)
540
+ try:
541
+ error_details = response.json()
542
+ error_msg = f"{error_msg}\nResponse: {json.dumps(error_details, indent=2)}"
543
+ except Exception:
544
+ try:
545
+ error_text = response.text
546
+ error_msg = f"{error_msg}\nResponse: {error_text[:500]}"
547
+ except Exception:
548
+ pass
549
+ raise RuntimeError(f"Error querying comention endpoint: {error_msg}") from e
550
+ except requests.exceptions.RequestException as e:
551
+ raise RuntimeError(f"Error querying comention endpoint: {e}") from e
552
+
553
+ zero_count = batch_original_size - batch_found_total
554
+ print(
555
+ f" Batch {batch_idx + 1} complete: {batch_found_total} found, "
556
+ f"{zero_count} very_low, {iteration} iterations"
557
+ )
558
+ return batch_company_volumes, batch_query_count, list(batch_remaining_set)
559
+
560
+ def get_comention_volumes_iterative(
561
+ self,
562
+ companies: list[str],
563
+ topic: str,
564
+ start_date: str,
565
+ end_date: str,
566
+ max_iterations_per_batch: int = 10,
567
+ max_workers: int | None = None,
568
+ ) -> tuple[dict[str, int], int, list[str]]:
569
+ """
570
+ Query comention endpoint using iterative per-batch approach.
571
+ Batches are processed in parallel via ThreadPoolExecutor.
572
+
573
+ For each batch of companies:
574
+ 1. Query the batch
575
+ 2. Remove found companies from the batch
576
+ 3. Re-query remaining companies in the same batch
577
+ 4. Stop when an iteration returns 0 new companies
578
+ 5. Move to the next batch
579
+
580
+ This is more efficient than the 3-pass approach because it:
581
+ - Handles each batch independently
582
+ - Stops as soon as no new companies are found
583
+ - Never re-queries already found companies
584
+ - Runs batches in parallel (ThreadPoolExecutor) for speed
585
+
586
+ Args:
587
+ companies: List of company IDs
588
+ topic: Topic string for the comention query
589
+ start_date: Start date in ISO format (YYYY-MM-DD)
590
+ end_date: End date in ISO format (YYYY-MM-DD)
591
+ max_iterations_per_batch: Maximum iterations per batch to prevent infinite loops (default 10)
592
+ max_workers: Max concurrent batch workers. Defaults to min(8, total_batches).
593
+
594
+ Returns:
595
+ Tuple of (company_volumes_dict, query_count, very_low_companies) where:
596
+ - company_volumes_dict: Dict mapping company_id -> total_chunks_count (chunks > 0)
597
+ - query_count: Number of API queries made
598
+ - very_low_companies: List of company IDs that have no chunks (0 or not found)
599
+ """
600
+ company_volumes: dict[str, int] = {}
601
+ very_low_companies: list[str] = []
602
+ query_count = 0
603
+ total_batches = (len(companies) + MAX_ENTITIES_IN_ANY_OF - 1) // MAX_ENTITIES_IN_ANY_OF
604
+ workers = max_workers if max_workers is not None else min(8, total_batches)
605
+
606
+ start_iso = f"{start_date}T00:00:00Z"
607
+ end_iso = f"{end_date}T23:59:59Z"
608
+
609
+ print(
610
+ f" [ITERATIVE MODE] Querying {len(companies)} companies in {total_batches} batches "
611
+ f"of {MAX_ENTITIES_IN_ANY_OF} (max_workers={workers})"
612
+ )
613
+ print(
614
+ f" Each batch iterates until no new companies are found (max {max_iterations_per_batch} iterations)"
615
+ )
616
+
617
+ with ThreadPoolExecutor(max_workers=workers) as executor:
618
+ futures = {
619
+ executor.submit(
620
+ self._process_single_batch_comention,
621
+ batch_idx,
622
+ companies,
623
+ topic,
624
+ start_iso,
625
+ end_iso,
626
+ max_iterations_per_batch,
627
+ total_batches,
628
+ ): batch_idx
629
+ for batch_idx in range(total_batches)
630
+ }
631
+ for future in as_completed(futures):
632
+ batch_volumes, batch_queries, batch_very_low = future.result()
633
+ company_volumes.update(batch_volumes)
634
+ query_count += batch_queries
635
+ very_low_companies.extend(batch_very_low)
636
+
637
+ print(
638
+ f" Completed {query_count} queries. Found {len(company_volumes)} companies with "
639
+ f"chunks > 0, {len(very_low_companies)} very_low"
640
+ )
641
+ return company_volumes, query_count, very_low_companies
642
+
643
+ def get_volume_timeseries(
644
+ self,
645
+ companies: list[str],
646
+ topic: str,
647
+ start_date: str,
648
+ end_date: str,
649
+ ) -> list[VolumePoint]:
650
+ """
651
+ Query the volume endpoint for a set of companies and return a time series.
652
+
653
+ Batches companies by MAX_ENTITIES_IN_ANY_OF; aggregates results by date
654
+ across all batches: documents and chunks are summed per date; sentiment
655
+ is the arithmetic mean of per-batch sentiment values for that date
656
+ (batches with no sentiment for a date are omitted from the mean).
657
+
658
+ Args:
659
+ companies: List of company IDs
660
+ topic: Topic string for the query
661
+ start_date: Start date in YYYY-MM-DD format
662
+ end_date: End date in YYYY-MM-DD format
663
+
664
+ Returns:
665
+ List of VolumePoint dicts with keys date, documents, chunks, sentiment,
666
+ sorted by date. Empty list on API error or empty response.
667
+ """
668
+ start_iso = f"{start_date}T00:00:00Z"
669
+ end_iso = f"{end_date}T23:59:59Z"
670
+ total_batches = (len(companies) + MAX_ENTITIES_IN_ANY_OF - 1) // MAX_ENTITIES_IN_ANY_OF
671
+
672
+ acc_docs: dict[str, int] = defaultdict(int)
673
+ acc_chunks: dict[str, int] = defaultdict(int)
674
+ acc_sentiment_sum: dict[str, float] = defaultdict(float)
675
+ acc_sentiment_count: dict[str, int] = defaultdict(int)
676
+
677
+ for batch_idx in range(total_batches):
678
+ batch_start = batch_idx * MAX_ENTITIES_IN_ANY_OF
679
+ batch_end = min(batch_start + MAX_ENTITIES_IN_ANY_OF, len(companies))
680
+ batch = companies[batch_start:batch_end]
681
+
682
+ payload = {
683
+ "query": {
684
+ "auto_enrich_filters": False,
685
+ "text": topic,
686
+ "filters": {
687
+ "timestamp": {"start": start_iso, "end": end_iso},
688
+ "entity": {
689
+ "all_of": [],
690
+ "any_of": batch,
691
+ "none_of": [],
692
+ },
693
+ },
694
+ "ranking_params": {
695
+ "source_boost": 1,
696
+ "freshness_boost": 0,
697
+ "reranker": {"enabled": False},
698
+ },
699
+ }
700
+ }
701
+
702
+ try:
703
+ response = requests.post(self.volume_url, json=payload, headers=self.headers)
704
+ response.raise_for_status()
705
+ data = response.json()
706
+ except requests.exceptions.HTTPError as e:
707
+ error_msg = str(e)
708
+ try:
709
+ error_details = response.json()
710
+ error_msg = f"{error_msg}\nResponse: {json.dumps(error_details, indent=2)}"
711
+ except Exception:
712
+ try:
713
+ error_msg = f"{error_msg}\nResponse: {response.text[:500]}"
714
+ except Exception:
715
+ pass
716
+ raise RuntimeError(f"Error querying volume endpoint: {error_msg}")
717
+ except requests.exceptions.RequestException as e:
718
+ raise RuntimeError(f"Error querying volume endpoint: {e}")
719
+
720
+ # Parse response: accept list at top level or under "results" / "volume"
721
+ raw_list = data.get("results", {}).get("volume", [])
722
+ for point in raw_list:
723
+ d = point.get("date")
724
+ if d is None:
725
+ continue
726
+ acc_docs[d] += int(point.get("documents") or 0)
727
+ acc_chunks[d] += int(point.get("chunks") or 0)
728
+ s = point.get("sentiment")
729
+ if s is not None:
730
+ acc_sentiment_sum[d] += float(s)
731
+ acc_sentiment_count[d] += 1
732
+
733
+ all_dates = sorted(
734
+ set(acc_docs.keys()) | set(acc_chunks.keys()) | set(acc_sentiment_count.keys())
735
+ )
736
+ out: list[VolumePoint] = []
737
+ for d in all_dates:
738
+ vp: VolumePoint = {
739
+ "date": d,
740
+ "documents": acc_docs[d],
741
+ "chunks": acc_chunks[d],
742
+ }
743
+ if acc_sentiment_count[d] > 0:
744
+ vp["sentiment"] = acc_sentiment_sum[d] / acc_sentiment_count[d]
745
+ out.append(vp)
746
+ return out
747
+
748
+ def filter_zero_volume(self, company_volumes: dict[str, int]) -> dict[str, int]:
749
+ """
750
+ Filter out companies with 0 chunks (no search needed).
751
+
752
+ Args:
753
+ company_volumes: Dict mapping company_id -> chunks
754
+
755
+ Returns:
756
+ Dict with only companies that have chunks > 0
757
+ """
758
+ return {cid: chunks for cid, chunks in company_volumes.items() if chunks > 0}
759
+
760
+ def group_by_volume(self, company_volumes: dict[str, int]) -> dict[str, list[tuple[str, int]]]:
761
+ """
762
+ Group companies into volume buckets.
763
+
764
+ Args:
765
+ company_volumes: Dict mapping company_id -> chunks
766
+
767
+ Returns:
768
+ Dict mapping bucket name -> list of (company_id, chunks) tuples, sorted by chunks descending
769
+ """
770
+ buckets = defaultdict(list)
771
+
772
+ for company_id, chunks in company_volumes.items():
773
+ if chunks >= VOLUME_BUCKETS["high"][0]:
774
+ buckets["high"].append((company_id, chunks))
775
+ elif chunks >= VOLUME_BUCKETS["medium"][0]:
776
+ buckets["medium"].append((company_id, chunks))
777
+ elif chunks >= VOLUME_BUCKETS["low"][0]:
778
+ buckets["low"].append((company_id, chunks))
779
+
780
+ # Sort each bucket by chunks (descending)
781
+ for bucket_name in buckets:
782
+ buckets[bucket_name].sort(key=lambda x: x[1], reverse=True)
783
+
784
+ return dict(buckets)
785
+
786
+ def create_baskets(
787
+ self,
788
+ company_volumes: dict[str, int],
789
+ max_chunks: int = MAX_CHUNKS_PER_BASKET,
790
+ very_low_companies: list[str] | None = None,
791
+ min_entities_per_basket: int = 1,
792
+ ) -> list[dict]:
793
+ """
794
+ Create baskets of companies with total chunks < max_chunks.
795
+
796
+ After the initial greedy packing, a post-processing step merges any
797
+ basket that has fewer than min_entities_per_basket entities into an
798
+ adjacent basket (respecting the MAX_ENTITIES_IN_ANY_OF hard limit).
799
+
800
+ Args:
801
+ company_volumes: Dict mapping company_id -> chunks (already filtered to exclude 0-chunk companies)
802
+ max_chunks: Maximum total chunks per basket
803
+ very_low_companies: Optional list of company IDs with 0 chunks (will be added to very_low baskets)
804
+ min_entities_per_basket: Minimum entities per basket. Baskets below this
805
+ threshold are merged with a neighbor. Default is 1 (no merging).
806
+
807
+ Returns:
808
+ List of basket dictionaries, each containing:
809
+ - basket_id: Unique identifier
810
+ - companies: List of company IDs
811
+ - total_chunks: Sum of chunks for all companies in basket
812
+ - company_count: Number of companies in basket
813
+ - volume_range: Volume range category
814
+ - company_chunks: Dict mapping company_id -> chunks for this basket
815
+ """
816
+ # Filter out zero-chunk companies
817
+ filtered_volumes = self.filter_zero_volume(company_volumes)
818
+
819
+ baskets: list[dict] = []
820
+ basket_counter = 0
821
+
822
+ if filtered_volumes:
823
+ # Group by volume
824
+ volume_groups = self.group_by_volume(filtered_volumes)
825
+
826
+ # Process each volume group
827
+ for volume_range, companies_list in volume_groups.items():
828
+ current_basket: dict = {
829
+ "companies": [],
830
+ "company_chunks": {},
831
+ "total_chunks": 0,
832
+ }
833
+
834
+ for company_id, chunks in companies_list:
835
+ # Check if adding this company would exceed the chunk limit OR entity limit
836
+ if (
837
+ current_basket["total_chunks"] + chunks > max_chunks
838
+ or len(current_basket["companies"]) >= MAX_ENTITIES_IN_ANY_OF
839
+ ):
840
+ # Save current basket and start a new one
841
+ if current_basket["companies"]:
842
+ baskets.append(
843
+ {
844
+ "basket_id": f"{volume_range}_basket_{basket_counter}",
845
+ "companies": current_basket["companies"],
846
+ "company_chunks": current_basket["company_chunks"].copy(),
847
+ "total_chunks": current_basket["total_chunks"],
848
+ "company_count": len(current_basket["companies"]),
849
+ "volume_range": volume_range,
850
+ }
851
+ )
852
+ basket_counter += 1
853
+
854
+ # Start new basket with this company
855
+ current_basket = {
856
+ "companies": [company_id],
857
+ "company_chunks": {company_id: chunks},
858
+ "total_chunks": chunks,
859
+ }
860
+ else:
861
+ # Add company to current basket
862
+ current_basket["companies"].append(company_id)
863
+ current_basket["company_chunks"][company_id] = chunks
864
+ current_basket["total_chunks"] += chunks
865
+
866
+ # Don't forget the last basket in this volume range
867
+ if current_basket["companies"]:
868
+ baskets.append(
869
+ {
870
+ "basket_id": f"{volume_range}_basket_{basket_counter}",
871
+ "companies": current_basket["companies"],
872
+ "company_chunks": current_basket["company_chunks"].copy(),
873
+ "total_chunks": current_basket["total_chunks"],
874
+ "company_count": len(current_basket["companies"]),
875
+ "volume_range": volume_range,
876
+ }
877
+ )
878
+ basket_counter += 1
879
+
880
+ # Process very_low companies (0 chunks) - max 500 companies per basket
881
+ if very_low_companies:
882
+ very_low_basket_counter = 0
883
+ for i in range(0, len(very_low_companies), MAX_ENTITIES_IN_ANY_OF):
884
+ batch = very_low_companies[i : i + MAX_ENTITIES_IN_ANY_OF]
885
+ baskets.append(
886
+ {
887
+ "basket_id": f"very_low_basket_{very_low_basket_counter}",
888
+ "companies": batch,
889
+ "company_chunks": {cid: 0 for cid in batch},
890
+ "total_chunks": 0,
891
+ "company_count": len(batch),
892
+ "volume_range": "very_low",
893
+ }
894
+ )
895
+ very_low_basket_counter += 1
896
+
897
+ if min_entities_per_basket > 1:
898
+ baskets = self._merge_small_baskets(baskets, min_entities_per_basket)
899
+
900
+ return baskets
901
+
902
+ def _merge_small_baskets(
903
+ self,
904
+ baskets: list[dict],
905
+ min_entities: int,
906
+ ) -> list[dict]:
907
+ """
908
+ Merge baskets that have fewer than min_entities companies into an
909
+ adjacent basket, respecting the MAX_ENTITIES_IN_ANY_OF hard limit.
910
+
911
+ Iteratively picks the smallest under-threshold basket and merges it
912
+ with the adjacent basket (previous preferred, then next) whose combined
913
+ entity count stays within the API entity limit.
914
+
915
+ Args:
916
+ baskets: List of basket dicts produced by the greedy packing step
917
+ min_entities: Minimum entities per basket
918
+
919
+ Returns:
920
+ Consolidated list of baskets
921
+ """
922
+ if len(baskets) <= 1:
923
+ return baskets
924
+
925
+ while True:
926
+ small_indices = [i for i, b in enumerate(baskets) if b["company_count"] < min_entities]
927
+ if not small_indices:
928
+ break
929
+ if len(baskets) <= 1:
930
+ break
931
+
932
+ target_idx = min(small_indices, key=lambda i: baskets[i]["company_count"])
933
+
934
+ # Find the best adjacent basket to merge with (entity-limit safe)
935
+ merge_idx = self._find_merge_neighbor(baskets, target_idx)
936
+ if merge_idx is None:
937
+ break
938
+
939
+ self._absorb_basket(baskets, source_idx=target_idx, dest_idx=merge_idx)
940
+
941
+ return baskets
942
+
943
+ @staticmethod
944
+ def _find_merge_neighbor(baskets: list[dict], target_idx: int) -> int | None:
945
+ """Return the index of the best adjacent basket to merge *target_idx* into.
946
+
947
+ Prefers the previous basket, then the next, skipping any whose combined
948
+ entity count would exceed MAX_ENTITIES_IN_ANY_OF.
949
+ """
950
+ target_count = baskets[target_idx]["company_count"]
951
+ candidates = []
952
+ if target_idx > 0:
953
+ candidates.append(target_idx - 1)
954
+ if target_idx < len(baskets) - 1:
955
+ candidates.append(target_idx + 1)
956
+
957
+ for idx in candidates:
958
+ if baskets[idx]["company_count"] + target_count <= MAX_ENTITIES_IN_ANY_OF:
959
+ return idx
960
+ return None
961
+
962
+ @staticmethod
963
+ def _absorb_basket(baskets: list[dict], source_idx: int, dest_idx: int) -> None:
964
+ """Merge *source_idx* basket into *dest_idx* basket and remove the source."""
965
+ source = baskets[source_idx]
966
+ dest = baskets[dest_idx]
967
+ dest["companies"].extend(source["companies"])
968
+ dest["company_chunks"].update(source["company_chunks"])
969
+ dest["total_chunks"] += source["total_chunks"]
970
+ dest["company_count"] = len(dest["companies"])
971
+ baskets.pop(source_idx)
972
+
973
+ def estimate_subperiod_volumes(
974
+ self,
975
+ company_total_chunks: int,
976
+ sub_period_start: str,
977
+ sub_period_end: str,
978
+ full_period_start: str,
979
+ full_period_end: str,
980
+ ) -> int:
981
+ """
982
+ Estimate chunk volume for a sub-period using uniform distribution.
983
+
984
+ Args:
985
+ company_total_chunks: Total chunks for the company in the full period
986
+ sub_period_start: Start date of sub-period (YYYY-MM-DD)
987
+ sub_period_end: End date of sub-period (YYYY-MM-DD)
988
+ full_period_start: Start date of full period (YYYY-MM-DD)
989
+ full_period_end: End date of full period (YYYY-MM-DD)
990
+
991
+ Returns:
992
+ Estimated chunks for the sub-period (rounded to nearest integer)
993
+ """
994
+ sub_start = datetime.strptime(sub_period_start, "%Y-%m-%d")
995
+ sub_end = datetime.strptime(sub_period_end, "%Y-%m-%d")
996
+ full_start = datetime.strptime(full_period_start, "%Y-%m-%d")
997
+ full_end = datetime.strptime(full_period_end, "%Y-%m-%d")
998
+
999
+ sub_period_days = (sub_end - sub_start).days + 1 # Inclusive
1000
+ total_period_days = (full_end - full_start).days + 1 # Inclusive
1001
+
1002
+ if total_period_days == 0:
1003
+ return 0
1004
+
1005
+ estimated = (company_total_chunks * sub_period_days) / total_period_days
1006
+ return max(0, int(round(estimated)))
1007
+
1008
+ def calculate_periods_needed(self, total_chunks: int) -> int:
1009
+ """
1010
+ Calculate how many periods are needed for a company based on its total chunks.
1011
+
1012
+ Args:
1013
+ total_chunks: Total chunks for the company
1014
+
1015
+ Returns:
1016
+ Number of periods needed (ceil(total_chunks / 1000))
1017
+ """
1018
+ return max(1, math.ceil(total_chunks / MAX_CHUNKS_PER_BASKET))
1019
+
1020
+ def consolidate_period_groups(
1021
+ self,
1022
+ companies_by_periods_needed: dict[int, dict[str, int]],
1023
+ min_entities_per_basket: int,
1024
+ ) -> dict[int, dict[str, int]]:
1025
+ """
1026
+ Merge period groups that have fewer than min_entities_per_basket entities
1027
+ into the nearest group by periods_needed distance.
1028
+
1029
+ Uses a greedy nearest-neighbor strategy: repeatedly pick the smallest
1030
+ under-threshold group and merge it with its closest neighbor (by
1031
+ |periods_needed| distance). The merged group is keyed by the maximum
1032
+ periods_needed of its constituents so that every company in the group
1033
+ gets enough time-window splits.
1034
+
1035
+ Args:
1036
+ companies_by_periods_needed: Dict mapping periods_needed -> {company_id: chunks}
1037
+ min_entities_per_basket: Minimum number of entities required per group
1038
+
1039
+ Returns:
1040
+ Consolidated dict with the same shape; groups below the threshold
1041
+ have been absorbed into their nearest neighbor.
1042
+ """
1043
+ if min_entities_per_basket <= 1:
1044
+ return companies_by_periods_needed
1045
+
1046
+ result: dict[int, dict[str, int]] = {
1047
+ p: dict(group) for p, group in companies_by_periods_needed.items()
1048
+ }
1049
+
1050
+ while True:
1051
+ small_groups = [p for p in result if len(result[p]) < min_entities_per_basket]
1052
+ if not small_groups:
1053
+ break
1054
+ if len(result) <= 1:
1055
+ break
1056
+
1057
+ target = min(small_groups, key=lambda p: len(result[p]))
1058
+
1059
+ candidates = [p for p in result if p != target]
1060
+ if not candidates:
1061
+ break
1062
+
1063
+ nearest = min(candidates, key=lambda p: abs(p - target))
1064
+
1065
+ new_key = max(target, nearest)
1066
+ old_key = min(target, nearest)
1067
+
1068
+ merged = {}
1069
+ merged.update(result.pop(old_key))
1070
+ merged.update(result.pop(new_key))
1071
+ result[new_key] = merged
1072
+
1073
+ return result
1074
+
1075
+ def determine_split_granularity(
1076
+ self,
1077
+ periods_needed: int,
1078
+ target_period_type: str,
1079
+ start_date: str,
1080
+ end_date: str,
1081
+ ) -> tuple[str, list[tuple[str, str]]]:
1082
+ """
1083
+ Split the date range into exactly periods_needed equal parts.
1084
+
1085
+ Args:
1086
+ periods_needed: Number of periods needed (ceil(total_chunks / 1000))
1087
+ target_period_type: Unused (kept for backward compatibility)
1088
+ start_date: Start date of full period (YYYY-MM-DD)
1089
+ end_date: End date of full period (YYYY-MM-DD)
1090
+
1091
+ Returns:
1092
+ Tuple of (period_type_label, list of (start, end) date tuples for periods)
1093
+ """
1094
+ if periods_needed <= 1:
1095
+ return ("full_range", [(start_date, end_date)])
1096
+
1097
+ # Split into exactly periods_needed equal parts
1098
+ start = datetime.strptime(start_date, "%Y-%m-%d")
1099
+ end = datetime.strptime(end_date, "%Y-%m-%d")
1100
+ total_days = (end - start).days + 1 # inclusive
1101
+
1102
+ periods = []
1103
+ for i in range(periods_needed):
1104
+ # Calculate start and end day for this period (integer division for even split)
1105
+ period_start_day = i * total_days // periods_needed
1106
+ period_end_day = (i + 1) * total_days // periods_needed - 1
1107
+
1108
+ period_start_dt = start + timedelta(days=period_start_day)
1109
+ period_end_dt = start + timedelta(days=period_end_day)
1110
+
1111
+ periods.append(
1112
+ (period_start_dt.strftime("%Y-%m-%d"), period_end_dt.strftime("%Y-%m-%d"))
1113
+ )
1114
+
1115
+ return (f"split_{periods_needed}", periods)
1116
+
1117
+ def determine_splits_from_volume(
1118
+ self,
1119
+ volume_series: list[VolumePoint],
1120
+ periods_needed: int,
1121
+ start_date: str,
1122
+ end_date: str,
1123
+ min_period_days: int = 30,
1124
+ ) -> tuple[str, list[tuple[str, str]]]:
1125
+ """
1126
+ Split the date range into sub-periods so that chunk volume is balanced
1127
+ across periods using the volume time series. Falls back to equal-length
1128
+ splits if the series is empty or has zero total chunks.
1129
+
1130
+ Args:
1131
+ volume_series: List of VolumePoint with at least "date" and "chunks"
1132
+ periods_needed: Number of periods to produce
1133
+ start_date: Full period start (YYYY-MM-DD)
1134
+ end_date: Full period end (YYYY-MM-DD)
1135
+ min_period_days: Minimum number of days per period
1136
+ Returns:
1137
+ Tuple of (period_type_label, list of (start, end) date tuples)
1138
+ """
1139
+ if periods_needed <= 1:
1140
+ return ("full_range", [(start_date, end_date)])
1141
+
1142
+ sorted_series = sorted(
1143
+ (p for p in volume_series if p.get("date")),
1144
+ key=lambda p: p.get("date", ""),
1145
+ )
1146
+ total_chunks = sum(p.get("chunks", 0) or 0 for p in sorted_series)
1147
+
1148
+ if not sorted_series or total_chunks <= 0:
1149
+ return self.determine_split_granularity(
1150
+ periods_needed, "biyearly", start_date, end_date
1151
+ )
1152
+ # Cumulative chunks and find boundary dates (one date per boundary to avoid zero-length periods)
1153
+ cumulative = 0
1154
+ boundaries: list[str] = []
1155
+ target_step = total_chunks / periods_needed
1156
+
1157
+ boundaries = self._detect_breakpoints(
1158
+ sorted_series, start_date, target_step, min_period_days
1159
+ )
1160
+
1161
+ periods: list[tuple[str, str]] = []
1162
+ period_starts = [start_date] + [self._next_day(d) for d in boundaries]
1163
+ period_ends = list(boundaries) + [end_date]
1164
+
1165
+ for s, e in zip(period_starts, period_ends):
1166
+ if s <= e:
1167
+ periods.append((s, e))
1168
+ else:
1169
+ periods.append((s, s))
1170
+
1171
+ if not periods:
1172
+ return self.determine_split_granularity(
1173
+ periods_needed, "biyearly", start_date, end_date
1174
+ )
1175
+
1176
+ return (f"split_{periods_needed}_volume", periods)
1177
+
1178
+ def _next_day(self, date_str: str) -> str:
1179
+ """Return the day after date_str (YYYY-MM-DD) as YYYY-MM-DD."""
1180
+ d = datetime.strptime(date_str, "%Y-%m-%d")
1181
+ next_d = d + timedelta(days=1)
1182
+ return next_d.strftime("%Y-%m-%d")
1183
+
1184
+ def _delta_days(self, date_str1: str, date_str2: str) -> int:
1185
+ """Return the number of days between date_str1 and date_str2 (YYYY-MM-DD)."""
1186
+ d1 = datetime.strptime(date_str1, "%Y-%m-%d")
1187
+ d2 = datetime.strptime(date_str2, "%Y-%m-%d")
1188
+ return (d2 - d1).days
1189
+
1190
+ def _detect_breakpoints(
1191
+ self, data: list[VolumePoint], start_date: str, max_chunks: int, min_period_days: int
1192
+ ) -> list[str]:
1193
+ """
1194
+ Detect breakpoints in the volume series using the cumulative chunks and delta cumulative delta days.
1195
+ It will return the dates where the breakpoints occur, ensuring that the period is at least min_period_days days long.
1196
+ And the period will not exceed the max_chunks limit.
1197
+ Args:
1198
+ data: List of VolumePoint with at least "date" and "chunks"
1199
+ start_date: Start date of the full period (YYYY-MM-DD). Used to calculate the delta days.
1200
+ max_chunks: Maximum chunks per basket
1201
+ min_period_days: Minimum number of days per period
1202
+ Returns:
1203
+ List of dates (YYYY-MM-DD) where the breakpoints occur
1204
+ """
1205
+ # Compute cumulative chunks and delta cumulative delta days from the volume series
1206
+ cumulative_chunks = 0
1207
+ cumulative_delta_days = 0
1208
+ for point in data:
1209
+ cumulative_chunks += point.get("chunks", 0) or 0
1210
+ cumulative_delta_days = self._delta_days(start_date, point.get("date", ""))
1211
+ point["cumulative_chunks"] = cumulative_chunks
1212
+ point["cumulative_delta_days"] = cumulative_delta_days
1213
+
1214
+ breakpoints = []
1215
+ current_chunks = 0
1216
+
1217
+ # We track the 'start date' of the current segment to calculate delta
1218
+ segment_start_days = 0
1219
+
1220
+ for i, entry in enumerate(data):
1221
+ # Calculate how many days have passed since the start of THIS segment
1222
+ # cumulative_delta_days is global, so we subtract the offset
1223
+ days_in_segment = entry["cumulative_delta_days"] - segment_start_days
1224
+
1225
+ # Check if adding this entry violates thresholds
1226
+ # Note: We use the entry's individual 'chunks' contribution
1227
+ if (current_chunks + entry["chunks"] > max_chunks) and (
1228
+ days_in_segment >= min_period_days
1229
+ ):
1230
+ # Record the index of the breakpoint
1231
+ breakpoints.append(i)
1232
+
1233
+ # Reset trackers for the new segment
1234
+ current_chunks = entry["chunks"]
1235
+ # The new segment's reference point is the previous entry's end
1236
+ segment_start_days = data[i - 1]["cumulative_delta_days"] if i > 0 else 0
1237
+ else:
1238
+ current_chunks += entry["chunks"]
1239
+
1240
+ boundaries = [data[i]["date"] for i in breakpoints]
1241
+ return boundaries
1242
+
1243
+ def sub_period_volumes_from_series(
1244
+ self,
1245
+ volume_series: list[VolumePoint],
1246
+ periods: list[tuple[str, str]],
1247
+ company_group: dict[str, int],
1248
+ ) -> list[dict[str, int]]:
1249
+ """
1250
+ For each sub-period, compute chunk volume per company from the volume series.
1251
+
1252
+ When the series is aggregated for the group, distributes each period's
1253
+ total chunks across companies proportionally to their full-period totals.
1254
+
1255
+ Args:
1256
+ volume_series: Time series with date and chunks
1257
+ periods: List of (start_date, end_date) for each sub-period
1258
+ company_group: Dict company_id -> total_chunks (full period)
1259
+
1260
+ Returns:
1261
+ List of dicts, one per period; each dict maps company_id -> chunks for that period
1262
+ """
1263
+ result: list[dict[str, int]] = []
1264
+ group_total = sum(company_group.values()) or 1
1265
+ by_date: dict[str, int] = {}
1266
+ for p in volume_series:
1267
+ d = p.get("date")
1268
+ if d:
1269
+ by_date[d] = by_date.get(d, 0) + (p.get("chunks") or 0)
1270
+
1271
+ for period_start, period_end in periods:
1272
+ period_chunks = 0
1273
+ start_dt = datetime.strptime(period_start, "%Y-%m-%d")
1274
+ end_dt = datetime.strptime(period_end, "%Y-%m-%d")
1275
+ current = start_dt
1276
+ while current <= end_dt:
1277
+ key = current.strftime("%Y-%m-%d")
1278
+ period_chunks += by_date.get(key, 0)
1279
+ current += timedelta(days=1)
1280
+ sub_period_volumes: dict[str, int] = {}
1281
+ for company_id, total in company_group.items():
1282
+ if group_total <= 0:
1283
+ sub_period_volumes[company_id] = 0
1284
+ else:
1285
+ proportion = total / group_total
1286
+ sub_period_volumes[company_id] = max(0, int(round(period_chunks * proportion)))
1287
+ result.append(sub_period_volumes)
1288
+ return result
1289
+
1290
+ def plan_all_periods(
1291
+ self,
1292
+ topic: str,
1293
+ start_date: str = START_DATE,
1294
+ end_date: str = END_DATE,
1295
+ volume_query_mode: str = "three_pass",
1296
+ max_iterations_per_batch: int = 10,
1297
+ universe_csv_path: str | None = None,
1298
+ apply_volume_splits: bool = True,
1299
+ min_period_days: int = 30,
1300
+ min_entities_per_basket: int = 1,
1301
+ ) -> dict:
1302
+ """
1303
+ Generate SMART batching plan with optimal granularity per company.
1304
+
1305
+ Phase 1: Query full period once to get total volumes
1306
+ Phase 2: Automatically determine optimal granularity for each company based on volume
1307
+ and create baskets using adaptive splitting (sub-periods so each query <= 1000 chunks)
1308
+
1309
+ Args:
1310
+ topic: Topic string for comention and semantic search queries
1311
+ start_date: Start date in YYYY-MM-DD format
1312
+ end_date: End date in YYYY-MM-DD format
1313
+ volume_query_mode: Method for querying volumes. Options:
1314
+ - "three_pass": Original 3-pass approach (query all, then verify twice)
1315
+ - "iterative": Per-batch iterative approach (query batch, remove found, repeat until empty)
1316
+ max_iterations_per_batch: Max iterations per batch when using "iterative" mode (default 10)
1317
+ universe_csv_path: Optional path to universe CSV; if not set, uses planner default.
1318
+ apply_volume_splits: If True (default), use volume time series to split periods per company.
1319
+ If False, use time-based granularity and estimated sub-period volumes only.
1320
+ min_period_days: Minimum number of days per period. When splitting by volume, we need to ensure
1321
+ that the period is at least min_period_days days long. Default is 30 days.
1322
+ min_entities_per_basket: Minimum number of entities per period group. Groups with fewer
1323
+ entities are merged with the nearest group by periods_needed distance. Default is 1
1324
+ (no consolidation).
1325
+
1326
+ Returns:
1327
+ Planning report with single SMART configuration
1328
+ """
1329
+ # Load universe
1330
+ companies = (
1331
+ self.load_universe(universe_csv_path) if universe_csv_path else self.load_universe()
1332
+ )
1333
+ total_companies = len(companies)
1334
+
1335
+ report = {
1336
+ "topic": topic,
1337
+ "period_range": {
1338
+ "start": start_date,
1339
+ "end": end_date,
1340
+ },
1341
+ "total_companies": total_companies,
1342
+ "configurations": {},
1343
+ }
1344
+
1345
+ # PHASE 1: Query full period once to get total volumes
1346
+ print("=" * 80)
1347
+ print(f"PHASE 1: Querying full period for all companies ({start_date} to {end_date})")
1348
+ print(f" Mode: {volume_query_mode}")
1349
+ print("=" * 80)
1350
+
1351
+ if volume_query_mode == "iterative":
1352
+ full_period_volumes, total_comention_queries, _ = self.get_comention_volumes_iterative(
1353
+ companies,
1354
+ topic,
1355
+ start_date,
1356
+ end_date,
1357
+ max_iterations_per_batch=max_iterations_per_batch,
1358
+ )
1359
+ else:
1360
+ # Default: three_pass mode
1361
+ full_period_volumes, total_comention_queries = self.get_comention_volumes(
1362
+ companies, topic, start_date, end_date
1363
+ )
1364
+ print(f"\nPhase 1 complete: {total_comention_queries} comention queries")
1365
+ print(f"Found {len(full_period_volumes)} companies with chunks > 0\n")
1366
+
1367
+ # Order the companies by chunks in descending order and company_id in ascending order
1368
+ # This is done to ensure deterministic results on the planning phase.
1369
+ full_period_volumes = dict(
1370
+ sorted(full_period_volumes.items(), key=lambda item: (-item[1], item[0]))
1371
+ )
1372
+
1373
+ # Calculate periods needed for each company
1374
+ company_periods_needed = {}
1375
+ for company_id, chunks in full_period_volumes.items():
1376
+ company_periods_needed[company_id] = self.calculate_periods_needed(chunks)
1377
+
1378
+ # Group companies by periods_needed
1379
+ companies_by_periods_needed = defaultdict(dict)
1380
+ for company_id, chunks in full_period_volumes.items():
1381
+ periods_needed = company_periods_needed[company_id]
1382
+ companies_by_periods_needed[periods_needed][company_id] = chunks
1383
+
1384
+ print("Company categorization by periods needed (before consolidation):")
1385
+ for periods_needed in sorted(companies_by_periods_needed.keys()):
1386
+ count = len(companies_by_periods_needed[periods_needed])
1387
+ print(f" {periods_needed} period(s) needed: {count} companies")
1388
+ print(f" Zero chunks: {total_companies - len(full_period_volumes)} companies\n")
1389
+
1390
+ # Consolidate small groups before fetching volume time series
1391
+ if min_entities_per_basket > 1:
1392
+ companies_by_periods_needed = self.consolidate_period_groups(
1393
+ dict(companies_by_periods_needed), min_entities_per_basket
1394
+ )
1395
+ print(
1396
+ f"Company categorization by periods needed (after consolidation, min_entities={min_entities_per_basket}):"
1397
+ )
1398
+ for periods_needed in sorted(companies_by_periods_needed.keys()):
1399
+ count = len(companies_by_periods_needed[periods_needed])
1400
+ print(f" {periods_needed} period(s) needed: {count} companies")
1401
+ print()
1402
+
1403
+ # PHASE 2: Fetch volume time series per company group (concurrent) for groups needing splits
1404
+ volume_by_group: dict[tuple[int, tuple[str, ...]], list[VolumePoint]] = {}
1405
+ groups_needing_volume = [
1406
+ (p, g)
1407
+ for p, g in sorted(companies_by_periods_needed.items())
1408
+ if p > 1 and apply_volume_splits
1409
+ ]
1410
+ if groups_needing_volume:
1411
+ print("Fetching volume time series for company groups (concurrent)...")
1412
+ with ThreadPoolExecutor(max_workers=min(8, len(groups_needing_volume))) as executor:
1413
+ future_to_key = {
1414
+ executor.submit(
1415
+ self.get_volume_timeseries,
1416
+ list(g.keys()),
1417
+ topic,
1418
+ start_date,
1419
+ end_date,
1420
+ ): (p, tuple(sorted(g.keys())))
1421
+ for p, g in groups_needing_volume
1422
+ }
1423
+ for future in as_completed(future_to_key):
1424
+ key = future_to_key[future]
1425
+ try:
1426
+ series = future.result()
1427
+ volume_by_group[key] = series
1428
+ except Exception as e:
1429
+ print(f" Volume fetch failed for group: {e}")
1430
+ volume_by_group[key] = []
1431
+ print(f" Fetched volume for {len(volume_by_group)} groups.\n")
1432
+
1433
+ # PHASE 2: Plan baskets using SMART configuration (single optimal plan)
1434
+ print("=" * 80)
1435
+ print("PHASE 2: Planning SMART configuration (optimal granularity per company)")
1436
+ print("=" * 80)
1437
+
1438
+ config_report = {
1439
+ "comention_queries": total_comention_queries,
1440
+ "semantic_queries": 0,
1441
+ "companies_with_chunks": len(full_period_volumes),
1442
+ "companies_with_zero_chunks": total_companies - len(full_period_volumes),
1443
+ "baskets": [],
1444
+ "period_details": [],
1445
+ "uses_estimates": False,
1446
+ "adaptive_splitting": True,
1447
+ "granularity_groups": {},
1448
+ }
1449
+
1450
+ total_semantic_queries = 0
1451
+ all_period_details = []
1452
+ uses_estimates = False
1453
+ granularity_groups = defaultdict(lambda: {"companies": 0, "periods": 0, "baskets": 0})
1454
+ global_basket_counter = 0
1455
+
1456
+ # Process each group of companies by periods_needed
1457
+ for periods_needed, company_group in sorted(companies_by_periods_needed.items()):
1458
+ group_key = (periods_needed, tuple(sorted(company_group.keys())))
1459
+ volume_series = volume_by_group.get(group_key) if periods_needed > 1 else None
1460
+ use_volume_splits = (
1461
+ apply_volume_splits
1462
+ and periods_needed > 1
1463
+ and volume_series is not None
1464
+ and len(volume_series) > 0
1465
+ and sum(p.get("chunks", 0) or 0 for p in volume_series) > 0
1466
+ )
1467
+
1468
+ if use_volume_splits:
1469
+ actual_period_type, actual_periods = self.determine_splits_from_volume(
1470
+ volume_series, periods_needed, start_date, end_date, min_period_days
1471
+ )
1472
+ sub_period_volumes_list = self.sub_period_volumes_from_series(
1473
+ volume_series, actual_periods, company_group
1474
+ )
1475
+ else:
1476
+ actual_period_type, actual_periods = self.determine_split_granularity(
1477
+ periods_needed, "biyearly", start_date, end_date
1478
+ )
1479
+ sub_period_volumes_list = []
1480
+
1481
+ n_per = len(actual_periods)
1482
+ period_word = "period" if n_per == 1 else "periods"
1483
+ print(f" Companies needing {periods_needed} period(s): {len(company_group)} companies")
1484
+ print(f" Using {actual_period_type} granularity ({n_per} {period_word})")
1485
+
1486
+ granularity_groups[actual_period_type]["companies"] += len(company_group)
1487
+ granularity_groups[actual_period_type]["periods"] = len(actual_periods)
1488
+
1489
+ # Process each period for this group
1490
+ for period_idx, (period_start, period_end) in enumerate(actual_periods):
1491
+ if use_volume_splits and period_idx < len(sub_period_volumes_list):
1492
+ sub_period_volumes = sub_period_volumes_list[period_idx]
1493
+ else:
1494
+ sub_period_volumes = {}
1495
+ for company_id, total_chunks in company_group.items():
1496
+ if periods_needed == 1:
1497
+ sub_period_volumes[company_id] = total_chunks
1498
+ else:
1499
+ estimated_chunks = self.estimate_subperiod_volumes(
1500
+ total_chunks,
1501
+ period_start,
1502
+ period_end,
1503
+ start_date,
1504
+ end_date,
1505
+ )
1506
+ if estimated_chunks > 0:
1507
+ sub_period_volumes[company_id] = estimated_chunks
1508
+ uses_estimates = True
1509
+
1510
+ # Create baskets for this sub-period
1511
+ baskets = self.create_baskets(
1512
+ sub_period_volumes,
1513
+ min_entities_per_basket=min_entities_per_basket,
1514
+ )
1515
+ total_semantic_queries += len(baskets)
1516
+ granularity_groups[actual_period_type]["baskets"] += len(baskets)
1517
+
1518
+ # Track companies with chunks in this sub-period
1519
+ companies_with_chunks = len(self.filter_zero_volume(sub_period_volumes))
1520
+
1521
+ # Update basket IDs: global counter, dates only when split, subdivision index (e.g. 1of6)
1522
+ period_start_short = period_start.replace("-", "")
1523
+ period_end_short = period_end.replace("-", "")
1524
+ for basket in baskets:
1525
+ volume_range = basket.get("volume_range", "basket")
1526
+ if n_per == 1:
1527
+ # No split: no date, no subdivision index
1528
+ basket["basket_id"] = f"{volume_range}_basket_{global_basket_counter}"
1529
+ else:
1530
+ # Split: basket number, subdivision index (1of6), then date range
1531
+ basket["basket_id"] = (
1532
+ f"{volume_range}_basket_{global_basket_counter}_"
1533
+ f"{period_idx + 1}of{n_per}_{period_start_short}_{period_end_short}"
1534
+ )
1535
+ global_basket_counter += 1
1536
+ basket["actual_granularity"] = actual_period_type
1537
+ basket["periods_needed"] = periods_needed
1538
+ basket["period_start"] = period_start
1539
+ basket["period_end"] = period_end
1540
+ basket["contains_estimates"] = periods_needed > 1 and not use_volume_splits
1541
+
1542
+ period_detail = {
1543
+ "period_index": period_idx,
1544
+ "start_date": period_start,
1545
+ "end_date": period_end,
1546
+ "actual_granularity": actual_period_type,
1547
+ "periods_needed": periods_needed,
1548
+ "companies_in_group": len(company_group),
1549
+ "comention_queries": 0, # No additional queries needed (using Phase 1 data)
1550
+ "semantic_queries": len(baskets),
1551
+ "companies_with_chunks": companies_with_chunks,
1552
+ "companies_with_zero_chunks": total_companies - companies_with_chunks,
1553
+ "baskets": baskets,
1554
+ "uses_estimates": periods_needed > 1 and not use_volume_splits,
1555
+ }
1556
+ all_period_details.append(period_detail)
1557
+
1558
+ config_report["sub_periods"] = len(all_period_details)
1559
+ config_report["period_details"] = all_period_details
1560
+ config_report["uses_estimates"] = uses_estimates
1561
+ config_report["granularity_groups"] = dict(granularity_groups)
1562
+
1563
+ config_report["semantic_queries"] = total_semantic_queries
1564
+
1565
+ # Calculate efficiency metrics
1566
+ total_chunks = sum(full_period_volumes.values())
1567
+ avg_chunks_per_query = (
1568
+ total_chunks / total_semantic_queries if total_semantic_queries > 0 else 0
1569
+ )
1570
+ utilization = (
1571
+ (avg_chunks_per_query / MAX_CHUNKS_PER_BASKET) * 100
1572
+ if total_semantic_queries > 0
1573
+ else 0
1574
+ )
1575
+
1576
+ config_report["efficiency_metrics"] = {
1577
+ "total_chunks": total_chunks,
1578
+ "semantic_search_queries": total_semantic_queries,
1579
+ "avg_chunks_per_query": round(avg_chunks_per_query, 2),
1580
+ "utilization_percent": round(utilization, 2),
1581
+ }
1582
+
1583
+ # Basket distribution by volume range
1584
+ basket_distribution = defaultdict(int)
1585
+ for period_detail in config_report["period_details"]:
1586
+ for basket in period_detail["baskets"]:
1587
+ basket_distribution[basket["volume_range"]] += 1
1588
+
1589
+ config_report["basket_distribution"] = dict(basket_distribution)
1590
+
1591
+ report["configurations"]["smart"] = config_report
1592
+
1593
+ return report