"""
Package-wide performance and behaviour configuration for aabpl.

Import and modify these flags at runtime to control algorithm behaviour:

    from aabpl import config
    config.USE_VEC_VERTEX_CHECKS = False   # revert to scalar vertex loop
    config.N_POINTS_TOTAL = 1_000_000      # tell the package your total workload
    config.disk_region_cache.clear()       # invalidate geometry cache

All mutable state here is module-level so changes take effect immediately
on the next call without reloading.
"""
from __future__ import annotations
from collections import OrderedDict as _OrderedDict

# ---------------------------------------------------------------------------
# Geometry build
# ---------------------------------------------------------------------------

# Use vectorised numpy batch evaluation of vertex containment/overlap checks
# in classify_subcell_quadrants_vec (disk_region_geometry.py).
# Set to False to fall back to the original scalar per-edge Python loop.
USE_VEC_VERTEX_CHECKS: bool = True

# Per-function CPU-time profiling via the time_func_perf decorator.
# OFF by default: when False the decorator is a zero-overhead passthrough.
# When True it records start/end process_time for every decorated call into
# test_performance.func_timer_dict (consumed by analyze_func_perf). Some of those
# decorated functions run once PER SEARCH POINT, where the wrapper adds ~3 us/call
# and grows an unbounded in-memory call log (~240 MB per 1e6 calls) — so leave
# this False in production. The benchmark harness (run_single_config) turns it on
# for the duration of a measured run; enable it manually if calling
# analyze_func_perf() yourself.
# TEMPORARILY True while collecting benchmark data — set back to False after
# testing (the production default should be False). See roadmap.md.
PROFILE_FUNC_TIMES: bool = False

# Note: the search loop now always uses the integer cell-key codec, the vectorized
# group masks, and the always-on (group x candidates) overlap batch — these were
# previously the experimental flags USE_INT_CELL_KEYS / VECTORIZED_SEARCH_LOOP /
# BATCH_OVERLAP / BATCH_OVERLAP_MIN_GROUP. Benchmarking settled them (always batch
# wins, 1.4-1.8x and growing with scale; all byte-identical to the old scalar loop),
# so they are baked in and the flags removed. See disk_aggregation.search_and_aggregate.

# Shared geometry cache: maps (r/spacing, nest_depth, include_boundary) → lookup result.
# Keyed on geometry only (no point data), so shared across all Grid instances.
# Clear with config.disk_region_cache.clear() to force a rebuild.
# Maximum number of geometry configs held in disk_region_cache before the
# least-recently-used is evicted (LRU: reuse moves an entry to the back; eviction
# pops the front in disk_region_geometry.py). Each entry covers one
# (r/spacing, nest_depth) combination.
#
# Memory footprint is small and bounded — it stores cell geometry only, never the
# point data, and nest_depth/spacing come from small candidate sets. Measured
# pickled entry sizes at r/spacing=1.414 (in-memory ~2-5x larger, but still tens
# of MB total for a full cache):
#     nd=0 → 0.05 MB,  nd=2 → 0.22 MB,  nd=4 → 1.03 MB,
#     nd=5 → 2.22 MB,  nd=6 → 4.64 MB   (~2x per +1 nest_depth; sub-4^nd thanks
#                                        to sparse pruning). Larger r/spacing
#                                        grows entries somewhat but stays in the
#                                        tens-of-MB range, never GB.
# The real cost of deep nesting is BUILD TIME, not memory: constructing the nd=6
# geometry once took ~97s (vs sub-second at low nd) — which is why
# choose_nest_depth caps around 4 in practice. Hence the cache: build once, reuse.
DISK_REGION_CACHE_MAXSIZE: int = 10*5
disk_region_cache: _OrderedDict = _OrderedDict()

# ---------------------------------------------------------------------------
# Override spacing / nest_depth for testing.  None = choose automatically.
# Set before calling radius_search:
#   import aabpl.config as config
#   config.FIXED_SPACING_RATIO = 2.0   # forces spacing = r / 2.0
#   config.FIXED_NEST_DEPTH = 1
# ---------------------------------------------------------------------------
FIXED_SPACING_RATIO: float | None = 2.0
FIXED_NEST_DEPTH: int | None = None
SINGLE_REGION: bool = False   # undocumented: collapse all regions into one during search

# Narrower than FIXED_NEST_DEPTH: caps the target quadtree depth for adaptive-
# routed runs (FIXED_NEST_DEPTH=None) without affecting routing -- useful when
# benchmarking/forcing a specific nd<0 (supercell) tag via _best_nd_tag, since
# supercells never read the quadtree beyond level 0 and building it to the
# grid's native depth is pure waste. Ignored whenever FIXED_NEST_DEPTH is set.
ADAPTIVE_NEST_DEPTH_CAP: int | None = None

# Safety cap on dense (n_block x n_candidates) pairwise distance-check buffers
# (dx/dy broadcast + inside-radius mask in disk_aggregation*.py). Each pair
# costs ~4 live float64 temporaries during the dx*dx+dy*dy<=r2 computation
# (dx, dy, dx**2+dy**2, and numpy's own intermediate), so this many BYTES
# translates to roughly bytes/(8*4) pairs. When n_block*n_candidates would
# exceed that, the block is processed in row-batches instead of one shot --
# prevents the multi-GB MemoryError crashes seen with extreme forced-nd/ppc
# combos (e.g. nd=-3 on sparse real data, or nd=4 at ppc so high the domain
# collapses to near the search radius) where chunk sizing can't keep pairs
# bounded. 1GB is a reasonable default for a single-process benchmark/
# interactive run; lower it on memory-constrained machines.
MAX_DIST_MATRIX_BYTES: int = 1_000_000_000

# When True, aggregate_point_data_to_cells_adaptive_nd (point_assignment.py) is
# used instead of the uniform-depth aggregate_point_data_to_cells: a coarse
# density scan decides, per spatial region, the max quadtree depth actually
# needed there (via the same best_nd_tag crossover model the hot loop uses),
# instead of building every region down to the grid's global native nest_depth
# unconditionally. Measured at 40-60%+ of total radius_search runtime before
# this, most of it wasted on regions sparse enough to only need nd<0
# (supercells, which never read past level 0) or a shallow nd. Validated
# (exhaustive correctness suite + adversarial cross-boundary dilation test,
# all clean) and measured (81.5%/99.2% node-count reduction and up to 2.35x
# wall-time on real datasets) -- now the default. Set False to force the old
# uniform-depth path (e.g. to isolate whether a regression came from here).
USE_ADAPTIVE_ND_AGGREGATION: bool = True

# EXPERIMENTAL, default OFF. When True, search_and_aggregate detects
# spatially disjoint source+target point clouds (connected components on a
# coarse r-sized grid, dependency-free numpy union-find) before row-strip
# planning, and sorts pts_source with component id as the primary key so
# each disjoint cloud's points are contiguous (reusing the snake-sort the
# function already does -- no separate reorder pass). The strip-building
# loop then forces a break at every component boundary, so a strip can
# never span a real gap between two disjoint clouds. Targets the residual
# ~21%% overhead measured on a checkerboard-gap stress test (5x5 grid of
# tiles, 3-tile gaps both directions) that remains even after the
# depth_by_block dense-loop fix (see that fix's comment further down this
# file). NOT validated in production yet -- prototype/benchmark only.
USE_DISJOINT_COMPONENT_SCOPING: bool = False

# REMOVED: USE_SIZE_WEIGHTED_SUPERCELL_MERGE was specific to the legacy
# row-strip merge pass in disk_aggregation_chunk_adaptive_nd.py, which has
# been removed now that the clean planner (_plan_chunks_clean) is the only
# chunk planner.

# EXPERIMENTAL, default OFF. The (now-removed) legacy planner's row-strip
# tag helpers used to estimate a row
# strip's density by querying the coarse density map for every occupied
# cell whose row falls in [cr_lo, cr_hi] -- a purely numeric range query
# with no check for whether that range is spatially coherent. When a strip
# happens to span two genuinely disjoint physical regions with an empty
# gap between them (confirmed possible even with today's unmodified sort:
# two same-nd-zone clusters far apart merge into one atomic strip because
# the zone-change check never sees a difference), the density estimate
# silently blends both regions' cells together, which can produce an
# nd/depth decision that fits neither region well. When True, uses
# _sparse_box_ppc_coherent instead of _sparse_box_ppc: cells are pre-
# labeled with a disjoint-component id (via _label_components_from_cell_keys,
# computed once alongside the density map, effectively free), and a query
# spanning >1 component is narrowed to the dominant one instead of blended.
# Prototype/benchmark only -- not validated against real-data timing yet.
USE_COHERENT_DENSITY_ESTIMATE: bool = False

# EXPERIMENTAL, default OFF, diagnostic-only so far (no reorder applied yet
# -- see the USE_FINAL_CHUNK_SORT guard in disk_aggregation_chunk_adaptive_nd.py).
# The idea: keep row/col-chunk PLANNING entirely on today's unmodified
# row+snake sort (so none of the planner's row-scan or src_row_bdry logic
# needs to change or is at risk of the earlier component-sort regression),
# but once every point's FINAL chunk assignment is known (an output of
# planning, not an input), sort pts_source by that final chunk id for the
# EXECUTION phase only. Distinct from USE_DISJOINT_COMPONENT_SCOPING: that
# flag sorted by raw disjoint-component id BEFORE planning ran, which is
# what broke things. Sorting by final chunk id AFTER planning cannot
# corrupt planning (it already finished), and lets genuinely scattered
# chunks (e.g. two same-nd components sitting side by side in the same row
# range, interleaved every row under snake order) become one contiguous
# block for the hot loop instead of scattered access.
USE_FINAL_CHUNK_SORT: bool = False

# REMOVED: USE_MAX_HOT_SPOT_DETECTION and USE_ALWAYS_COLUMN_SCAN were
# specific to the legacy row/column density-blending planner in
# disk_aggregation_chunk_adaptive_nd.py, which has been removed now that
# the clean planner (_plan_chunks_clean) is the only chunk planner.

# EXPERIMENTAL, default OFF. See the long comment at its use site in
# disk_aggregation_chunk_adaptive_nd.py (_same_nd_mechanism) -- nd-
# heterogeneity banding logic groups ALL supercell-tagged columns into one
# band regardless of depth. Reuses MAX_ND_GAP_FOR_LOOSE_SUPERCELL_MERGE.
USE_MAGNITUDE_AWARE_SUPERCELL_BANDING: bool = False
MAX_ND_GAP_FOR_LOOSE_SUPERCELL_MERGE: int = 2

# REMOVED: USE_EXACT_COL_DENSITY was specific to the legacy planner's
# _greedy_col_chunks/_plan_col_chunks column-density lookups, which have
# been removed now that the clean planner is the only chunk planner.

# The chunk planner (see the long comment at its definition in
# disk_aggregation_chunk_adaptive_nd.py, _plan_chunks_clean): grows
# row-groups by comparing each candidate row's real sparse density profile
# against the group's accumulated profile (merge-join, not a blended
# scalar), and reuses that same accumulated profile directly for column
# banding -- avoids the 1D-marginal blind spot the old (now removed)
# row/column density-blending planner had on both axes. Validated and now
# the only chunk planner (the USE_CLEAN_CHUNK_PLANNER gate that used to
# select between it and the legacy planner has been removed).
CLEAN_PLANNER_PENALTY_THRESHOLD: float = 0.15

# Lookahead/noise damping for the clean planner's row-grouping loop --
# always on (not gated) since it's a strict precision IMPROVEMENT, mirroring
# the old legacy planner's row-grouping lookahead mechanism: a single
# row whose fit-penalty exceeds the threshold doesn't immediately cut a
# group; it must be confirmed by this many consecutive rows before the cut
# is treated as real, damping single-row noise instead of over-fragmenting.
CLEAN_PLANNER_LOOKAHEAD_ROWS: int = 2

# Validated this session: consistent, real speedups (60% on 15k/r=100, ~8%
# on 521k/r=100) with zero correctness regressions across every test run
# (15k full radius sweep, 521k, axis-distorted, 10-tile disjoint case).
# Skips the exact _fit_penalty comparison (confirmed ~73% of the clean
# planner's own cost) when a cheap, conservative blended check (row's mean
# AND max ppc both agree with the group's own mean/max) is confident the
# row obviously fits. Deliberately strict (both statistics must agree,
# using max specifically to avoid the mean-only blind spot to small hot
# sub-regions) -- any ambiguity falls through to the exact check, so this
# only ever skips cases that were always going to pass anyway. Promoted to
# default True; set False to isolate whether a regression came from here.
USE_CLEAN_PLANNER_PREFILTER: bool = True

# Validated this session: consistent additional speedup layered on top of
# the pre-filter (~12% further reduction), zero correctness regressions.
# Precomputes, once and fully vectorized, which consecutive atomic rows are
# pairwise mechanism-identical (same check as the pre-filter, applied to
# the whole dataset upfront rather than one candidate row at a time). A run
# of pairwise-identical rows is, by transitivity, uniform throughout --
# confirmed this session (15k, r=100) that once the pre-filter reaches a
# 100% skip rate, the REMAINING cost is pure per-row Python loop overhead
# (row_profile/merge_profiles called once per atomic row), not the
# comparison logic. This lets such a run be merged via ONE range query
# instead of looping row by row. Reaches the exact same group boundaries as
# the sequential path -- not an approximation -- any run shorter than 2
# rows, or where pairwise agreement breaks, falls straight through to the
# unchanged sequential logic. Promoted to default True.
USE_CLEAN_PLANNER_FASTPATH: bool = True

# EXPERIMENTAL, default OFF. Actually APPLIES the final-chunk-id sort
# USE_FINAL_CHUNK_SORT only ever diagnosed -- reorders every source-side
# per-point working array (cols, rows, point_xy, point_offset, cell_region,
# region_and_trgl -- never pts_source itself, never target-side arrays) so
# each final chunk becomes one contiguous slice, then maps results back to
# pts_source's original row order via the inverse permutation at the final
# write-back step. Safe where sorting by raw component id BEFORE planning
# was not (see this session's long discussion): every point's chunk
# membership is already fully decided by the time this runs, so nothing
# downstream can be corrupted by the reorder -- planning already finished.
USE_CLEAN_PLANNER_CHUNK_SORT: bool = False

# EXPERIMENTAL, default 1.0 (no change). Scales the L3 budget used ONLY by
# the clean planner's ROW-extension gate (how tall a row-group is allowed to
# grow before the group is forced to stop, in _plan_chunks_clean) -- does
# NOT affect _band_from_profile's column-level L3 check, and does NOT touch
# the global config.L3_BYTES or the legacy planner's own L3 checks. Scoped
# this narrowly (not a global L3 bump) specifically to investigate whether
# a looser row-height budget changes planning outcomes/timing in isolation,
# without also loosening the column-band memory-safety check that keeps
# final chunks within the real L3 budget regardless of this setting.
CLEAN_PLANNER_ROW_L3_BUDGET_MULT: float = 1.0

# Scales the point-count cap used by USE_DISJOINT_COMPONENT_SCOPING's
# merge-fold step (small clusters are folded together, in ascending-x
# order, until the running total clears 5% * DISJOINT_MERGE_CAP_MULT of
# l3_budget/pt_bytes) -- does NOT touch the global L3 budget itself, only
# how aggressively small disjoint clusters get grouped together before
# being planned as one unit. Default 1.0 (the original 5%-of-cap
# threshold). Confirmed this session (30-tile synthetic stress test,
# r=750) that raising this to 2.0 meaningfully helps: fewer, larger merged
# groups (61 -> 16 groups) means fewer per-group planning/execution fixed
# costs paid, cutting the disjoint-vs-non-disjoint ratio from 1.450x to
# 1.241x with correctness unaffected (MATCH=True both ways) -- the
# trade-off is less spatial tightness per group (larger groups can span
# more of the dataset), which matters more as this is pushed higher.
# Promoted to 3.0 after confirming 2.0 helped with no correctness cost --
# see the 30-tile stress test comparison in this session's history.
DISJOINT_MERGE_CAP_MULT: float = 3.0

# Bounds on the nd range _ensure_deep_nd (disk_aggregation_chunk_adaptive_nd.py)
# is allowed to extend geometry into on demand. The actual build-vs-skip
# decision is the amortization check at the call site (predicted time saved
# vs predicted build cost * GEO_AMORTIZATION_WEIGHT) -- these two just bound
# the range that check is ever allowed to pick from. DEEP_ND_MAX_BUILD
# defaults to 8: from-scratch build time grows super-exponentially with nd
# (measured this session, sr=2, r=750: nd=6 32.8s, nd=7 99.2s, nd=8 484.2s,
# ~4.7x/level near the top), and predict_timing's geo_s estimate is
# uncalibrated (extrapolated) past whatever nd values it was actually fit
# against -- raise this only after benchmarking further with
# time_nd_builds.py-style measurements at the deeper levels.
DEEP_ND_MIN_BUILD: int = 0
DEEP_ND_MAX_BUILD: int = 8

# Validated this session: for self-search (source==target, the common
# case), the source-weighted density map is constructed identically to the
# target one, so decisions are PROVABLY identical (not just empirically
# tested) to the target-weighted default -- confirmed via matching n_groups/
# n_chunks/merge-count debug stats, not just matching final sums -- while
# being consistently faster (2.4-2.7x on 15k, real wins on 521k) since the
# comparison universe shrinks to source-populated columns only. Promoted to
# default True. NOTE: only validated for self-search so far -- genuine
# non-self-search (source != target dataset) behavior/benefit is untested;
# if a regression shows up there, this is the first thing to check.
# _fit_penalty's merge-join comparison (used by the clean planner's row-
# extension loop) is TARGET-point-mass weighted when this is False --
# acc_cols/cand_cols are built from the target density map, so a column
# with lots of target points but no actual SOURCE points can dominate the
# merge decision, while a column where source points actually sit (and
# would pay the real cost of a wrong nd choice) gets diluted away. When
# True, the comparison universe is restricted to columns with SOURCE points
# on either side, and the bad/total mass ratio is weighted by SOURCE point
# counts instead of target mass -- target profiles are still used only to
# resolve which nd tag each side would pick at that column.
USE_SOURCE_WEIGHTED_FIT_PENALTY: bool = True

# EXPERIMENTAL, default OFF. Requires USE_SOURCE_WEIGHTED_FIT_PENALTY. Replaces
# the flat mass-weighted disagreement fraction with an actual estimated-ms
# cost delta (via nd_choice.cost_at_array): for each disagreeing column,
# compares cost(both sides at their own individually-optimal tag) against
# cost(both sides at whichever single tag minimizes their combined cost --
# i.e. what the merged group would actually end up using). Bookended by a
# floor (below COST_WEIGHTED_PENALTY_FLOOR_MS total cost, always merge --
# per-strip bookkeeping overhead would dominate any relative savings) and a
# ceiling (above COST_WEIGHTED_PENALTY_CEILING_MS absolute wasted ms, always
# split, regardless of how small the wasted/baseline ratio looks -- guards
# against a large group's big denominator hiding a real absolute mistake,
# the L3x100 axis-distorted failure mode found this session). In between,
# the decision is the scale-invariant ratio wasted/baseline compared against
# CLEAN_PLANNER_PENALTY_THRESHOLD, same threshold the flat version uses.
USE_COST_WEIGHTED_FIT_PENALTY: bool = False
# FLOOR_MS calibrated this session via a controlled never-merge-vs-always-merge
# comparison (prefilter disabled, so threshold=0/1 cleanly isolates the row-merge
# decision from prefilter's separate accept path): 15k dataset gave ~12.9ms/chunk
# (52 extra chunks -> 671.9ms), 521k dataset gave ~24.4ms/chunk (210 extra chunks
# -> 5125.0ms) -- consistent order of magnitude across a 34x data-size difference,
# supporting this as a genuine fixed per-chunk cost rather than something that
# scales with data volume. Set conservatively toward the larger (521k) estimate.
# Still only two data points at one radius (r=750) -- worth widening before
# trusting this far from that regime.
COST_WEIGHTED_PENALTY_FLOOR_MS: float = 25.0
# CEILING_MS NOT YET CALIBRATED -- placeholder. Needs a dedicated experiment
# isolating one genuinely-bad merge decision's absolute wasted cost (something
# cheaper than the full axis-distorted 521k case, which demonstrated the
# failure mode but at ~7 minutes per run -- too slow to calibrate against
# directly). Do not trust this default without running that experiment.
COST_WEIGHTED_PENALTY_CEILING_MS: float = 500.0  # TODO calibrate empirically

# EXPERIMENTAL, default OFF. Row-axis-only smoothing (1D, no column-axis
# dilution) of the CANDIDATE row's profile used in _fit_penalty's row-merge
# comparison -- mitigates arbitrary coarse-grid-boundary placement noise
# (a genuinely uniform region straddling a coarse-row boundary by chance can
# otherwise look like a real density jump between two adjacent coarse rows,
# purely from where the grid origin happened to fall, not from any real
# heterogeneity). Blends the row's own profile (weight ROW_DILUTION_OWN_WEIGHT)
# with the AVERAGE of whichever of {row above, row below} actually exist (own
# excluded from that average; it already has its own explicit weight) --
# own-cell value deliberately dominates so real, local density still wins
# out over the diluted neighbor estimate. Does NOT touch the accumulated
# group side (acc_cols/acc_pts, already multi-row and far less sensitive to
# a single boundary), the fastpath (which requires provably-exact pairwise
# agreement -- diluting there would defeat its safety guarantee), or the
# prefilter's separate mean/max lookup (a different, coarser check).
USE_ROW_DILUTED_PENALTY: bool = False
ROW_DILUTION_OWN_WEIGHT: float = 0.66

# DIAGNOSTIC ONLY, default OFF. Reports disjoint-cloud cluster count/sizes
# (via _find_disjoint_clouds, source points only) right before the chunk
# planner runs, plus a simple greedy point-count bin-packing of clusters
# into L3-sized groups -- does NOT feed into planning or execution at all,
# purely a print for understanding cluster structure before building the
# real disjoint-cluster-scoped planner. Point-count based (not the real
# nd_mem_scale-aware cost model) and deliberately not pessimistic about the
# L3 cap -- a cluster is allowed to push a group slightly over budget before
# a new group starts, rather than conservatively avoiding any overshoot.
_DEBUG_REPORT_DISJOINT_CLUSTERS: bool = False

# EXPERIMENTAL, default OFF. The depth_by_block pre-pass re-decides the nd
# tag for EVERY column chunk via _exact_chunk_nd (expensive: when
# USE_WEIGHTED_ND_DECISION is on, this runs best_nd_tag_weighted's ~50-
# candidate cost_at_array search from scratch), even though _plan_col_chunks
# already computed a cheap per-band hint (_col_chunk_nd_hints, via a single
# _best_nd_tag lookup) for the exact same band while building it. Normally
# this redundancy is negligible (a handful of chunks per strip). But when a
# strip is dense enough to need single-row (h=1) chunking -- confirmed this
# session via an anisotropically-distorted real dataset where EVERY one of
# 38 row-strips degenerated to h=1 and fragmented into 150-900+ column
# chunks each (avg ~2,790/strip) -- the expensive redecision runs tens of
# thousands of times, and profiling showed it consumed the large majority
# (~55%) of total runtime (324s of 589s on that case). When True, strips
# with more than COL_CHUNK_ND_CHEAP_THRESHOLD column chunks skip the
# expensive per-chunk redecision and reuse the cheap hint instead.
USE_CHEAP_COL_CHUNK_ND: bool = False
COL_CHUNK_ND_CHEAP_THRESHOLD: int = 20

# When True, the per-chunk nd decision in disk_aggregation_chunk_adaptive_nd.py
# uses nd_choice.best_nd_tag_weighted instead of best_nd_tag: a point-count-
# weighted sum of the cost model over every occupied coarse block (correctly
# reflecting a chunk's real density mixture and occupancy, not just a single
# p75-of-densities percentile that's blind to how many blocks are actually
# occupied), blended by an occupancy-aware alpha with a total-chunk-area ppc.
# Validated as a net positive on both real test datasets earlier this
# session (higher per-decision cost, but better-chosen nd more than pays
# for it) -- promoted to the default.
USE_WEIGHTED_ND_DECISION: bool = True

# Fixed per-chunk overhead (ms) assumed when deciding whether to merge two
# adjacent column-bands with different nd tags: merge only if the estimated
# joint processing time (nd_choice.blended_cost_ms) beats the sum of the two
# separate times plus this overhead. Modeling real per-chunk setup/teardown
# cost. (Same-tag row-strip/column-band merges skip this trade-off entirely
# -- they always merge if they fit the L3 budget, since there's no
# different-nd cost difference to weigh.)
# 2ms is a rough, deliberately modest default -- direct measurement on real
# datasets (15k/521k points) showed no clean, low-noise value: the chunk
# count and wall-clock time did not vary monotonically with tested overhead
# values (0/2/10ms), most likely swamped by ordinary run-to-run system
# noise on the scale of individual chunk timings. Revisit if a cleaner
# measurement setup (isolated machine, more reps) becomes available.
CHUNK_MERGE_OVERHEAD_MS: float = 2.0

# ---------------------------------------------------------------------------
# TEMPORARY bisection toggles (2026-07-14) -- default False = today's fixes
# stay active. Set True to revert that ONE specific change for regression
# testing, without deleting any code. Remove this whole block (and the
# guards using them) once the regression is found and confirmed fixed.
# (The row/col-zone-noise, row-merge, col-split-trigger, and greedy-col-
# mergeback revert toggles that used to live here were removed along with
# the legacy planner they guarded.)
# ---------------------------------------------------------------------------
_DEBUG_REVERT_MEM_SCALE_FIX: bool = False          # _nd_mem_scale: flat 0.5 for nd<0 instead of 2**nd

# ---------------------------------------------------------------------------
# Workload hints — used to inform optimal spacing / nest_depth selection
# ---------------------------------------------------------------------------

# Total number of source points the user expects to process across all calls
# in this session.  Set this before running if you know your workload upfront —
# it allows the package to favour heavier geometry (higher build cost, lower
# search cost per point) when the build amortises over enough points.
# None → the package will estimate from the current call size and N_POINTS_CUM.
N_POINTS_TOTAL: int | None = None

# ---------------------------------------------------------------------------
# Developer mode
# ---------------------------------------------------------------------------

# When True, enables verbose internal diagnostics and skips some guards.
DEV_MODE: bool = False

# Running count of source points processed so far this session.
# Incremented automatically after each search_and_aggregate call.
# Reset to 0 between independent sessions if needed.
N_POINTS_CUM: int = 0

# ---------------------------------------------------------------------------
# Wall-to-CPU time ratio
# ---------------------------------------------------------------------------

# Ratio of wall time to CPU (process) time observed during recent runs.
# Values above 1.0 indicate the machine is under parallel load.
# Updated automatically by progress bars; used to convert CPU-based EMA
# estimates to wall-time ETA for display.
WALL_TO_CPU_RATIO: float = 1.3


# ---------------------------------------------------------------------------
# Geometry Amortization Weights
# ---------------------------------------------------------------------------

# Factor used to discount the upfront geometry build cost (geo_s).
# Reflects the expected number of query cycles per geometry lifecycle.
# A value of 0.75 assumes the build cost pays off across multiple runs,
# preventing the optimizer from over-penalizing heavy indexing structures.
GEO_AMORTIZATION_WEIGHT: float = 0.75


# Only for dev purposes and experimental.
USE_OPTIMIZED_METHOD = False
VALIDATE = False

# When True, radius_search with any area_weight runs a post-hoc accuracy check:
# picks 5 source points near each decile (0,10,...,100) of the computed
# valid_area_share distribution, computes the exact circle ∩ study_area
# fraction via Shapely, and prints a list of 11 (computed, exact) mean pairs.
# Slow (Shapely intersection per sampled point) — leave False in production.
VALIDATE_AREA: bool = False

# ---------------------------------------------------------------------------
# Chunk-path cache budget (disk_aggregation_chunk.py)
# ---------------------------------------------------------------------------

# L2 cache size in bytes.  Currently used only as the budget for the
# column-split fallback (when a single row exceeds the L3 budget, the col
# splitter falls back to L2 to keep each sub-block hot).  Set to your CPU's
# L2 size for best col-split behaviour; the default (256 KB) is conservative
# and safe on virtually all modern CPUs.
L2_BYTES: int = 256 * 1024

# L3 cache size in bytes.  Controls the primary chunk-row sizing: each chunk's
# block of source points is kept below this budget so it fits in L3 during the
# aggregation pass.  Set to your CPU's L3 size for best throughput.
# Auto-detection is intentionally omitted (subprocess/WMI calls are not
# antivirus-safe in a library).  Detect once with a one-off script and set
# this to the reported value.  Conservative default: 6 MB.
#
# To detect on Windows (run outside the package, once):
#   import subprocess, re
#   out = subprocess.check_output(['wmic', 'cpu', 'get', 'L3CacheSize'], text=True)
#   print(int(re.search(r'\d+', out).group()) * 1024, 'bytes')
L3_BYTES: int = 6 * 1024 * 1024