AbstractIntegratedModule 0.9.6__tar.gz → 0.9.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.egg-info/PKG-INFO +2 -2
  2. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.py +377 -100
  3. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/AbstractOptimizedModules.c +200 -200
  4. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/PKG-INFO +2 -2
  5. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/README.md +1 -1
  6. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/setup.py +1 -1
  7. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.egg-info/SOURCES.txt +0 -0
  8. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.egg-info/dependency_links.txt +0 -0
  9. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.egg-info/requires.txt +0 -0
  10. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.egg-info/top_level.txt +0 -0
  11. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/AbstractOptimizedModules.pyx +0 -0
  12. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/MANIFEST.in +0 -0
  13. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/abstract_model_storage/Cargo.toml +0 -0
  14. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/abstract_model_storage/pyproject.toml +0 -0
  15. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/abstract_model_storage/src/lib.rs +0 -0
  16. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/debug/build/libsqlite3-sys-ed07b882cd2aa5e2/out/bindgen.rs +0 -0
  17. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/debug/build/serde_core-ebc15f2e9cad7f5f/out/private.rs +0 -0
  18. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/debug/build/target-lexicon-08527f45de28143d/out/host.rs +0 -0
  19. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/release/build/libsqlite3-sys-bf0400df4523274c/out/bindgen.rs +0 -0
  20. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/release/build/serde_core-5cdb76131825e4af/out/private.rs +0 -0
  21. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/release/build/target-lexicon-43eb95a0588bf457/out/host.rs +0 -0
  22. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/pyproject.toml +0 -0
  23. {abstractintegratedmodule-0.9.6 → abstractintegratedmodule-0.9.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AbstractIntegratedModule
3
- Version: 0.9.6
3
+ Version: 0.9.8
4
4
  Summary: Library for Advanced Integrated Non-LLM AI Models - Optimized Backend Framework For Non-LLM AI Agent
5
5
  Author: Micro-Novelty
6
6
  Author-email: hernikpuspita5@gmail.com
@@ -42,7 +42,7 @@ https://github.com/Micro-Novelty/IntegratedPipeline-Specialized-Non-LLM-AI-Agent
42
42
  #### Note: The README here you are reading is a direct copy from my README Repository, to download the necessary files, you can visit my Repository with the provided link above.
43
43
 
44
44
  ### Library Short Description:
45
- - Development Stage: 0.9.6 Official Release.
45
+ - Development Stage: 0.9.8 Official Release.
46
46
  - Maintainer: Micro-Novelty.
47
47
  - library Source-Code is Open-sourced on github.
48
48
  - Purpose: Specifically Designed for providing Non-LLM AI Agent Framework for edge Devices, Optimized for ARM64 architecture.
@@ -1950,10 +1950,7 @@ class MLP:
1950
1950
 
1951
1951
  if isinstance(self.pred_counts[c], (int, float)) and self.pred_counts[c] > 0:
1952
1952
  error_rate = self.error_counts[c] / (self.pred_counts[c] + 1e-8)
1953
- # sigmoid-shaped dampening — never goes negative
1954
- # error_rate=0.0 → multiplier=1.0 (no change)
1955
- # error_rate=0.5 → multiplier≈0.67
1956
- # error_rate=1.0 → multiplier≈0.5
1953
+
1957
1954
  reputation = 1.0 / (1.0 + error_rate)
1958
1955
  if c < len(prob):
1959
1956
  prob[c] *= reputation
@@ -3173,8 +3170,6 @@ class WeightedEnsemblePredictor:
3173
3170
 
3174
3171
 
3175
3172
 
3176
-
3177
-
3178
3173
  def explainability_prediction_batch(self, texts, mlp_probs, trans_probs, attn_weights, show_explanation=False):
3179
3174
  results = []
3180
3175
  for text in texts:
@@ -3186,7 +3181,6 @@ class WeightedEnsemblePredictor:
3186
3181
  return results
3187
3182
 
3188
3183
 
3189
-
3190
3184
 
3191
3185
  def predict_ensemble(self, input_ids, X_mlp, y_true, method='dynamic', embedded=False):
3192
3186
  label_bins=None
@@ -3235,7 +3229,9 @@ class WeightedEnsemblePredictor:
3235
3229
  elif method == 'meta':
3236
3230
  # Meta-learner that decides weights
3237
3231
  ensemble_probs = self._meta_ensemble(
3238
- trans_probs, mlp_probs, attn_weights, X_mlp
3232
+ trans_probs, mlp_probs, attn_weights, X_mlp,
3233
+ lstm_probs=lstm_probs,
3234
+ lstm_weight_hint=lstm_weight_hint
3239
3235
  )
3240
3236
 
3241
3237
  elif method == 'calibration':
@@ -3439,7 +3435,6 @@ class WeightedEnsemblePredictor:
3439
3435
 
3440
3436
 
3441
3437
  def _attention_weighted_ensemble(self, trans_probs, mlp_probs, attn_weights):
3442
-
3443
3438
  if attn_weights is None:
3444
3439
  return (trans_probs + mlp_probs) / 2
3445
3440
 
@@ -3448,8 +3443,8 @@ class WeightedEnsemblePredictor:
3448
3443
 
3449
3444
  n_trans_classes = trans_probs.shape[1]
3450
3445
  n_mlp_classes = mlp_probs.shape[1]
3451
- n_classes = max(n_trans_classes, n_mlp_classes)
3452
-
3446
+
3447
+ n_classes = max(n_trans_classes, n_mlp_classes)
3453
3448
  for i in range(batch_size):
3454
3449
  trans_row = np.zeros(n_classes)
3455
3450
  mlp_row = np.zeros(n_classes)
@@ -3486,35 +3481,76 @@ class WeightedEnsemblePredictor:
3486
3481
 
3487
3482
  return ensemble
3488
3483
 
3489
- def _meta_ensemble(self, trans_probs, mlp_probs, attn_weights, X_mlp):
3484
+ def _meta_ensemble(self, trans_probs, mlp_probs, attn_weights, X_mlp,
3485
+ lstm_probs=None, lstm_weight_hint=0.0):
3486
+ lstm_row = None
3487
+
3488
+ if trans_probs.ndim == 1: trans_probs = trans_probs[np.newaxis, :]
3489
+ if mlp_probs.ndim == 1: mlp_probs = mlp_probs[np.newaxis, :]
3490
+
3491
+ B = trans_probs.shape[0]
3492
+
3490
3493
  batch_size = trans_probs.shape[0]
3491
- n_classes = trans_probs.shape[1]
3492
3494
  threshold_feature = 0.1 + self.pipeline.confidence_threshold
3493
-
3495
+
3494
3496
  n_trans_classes = trans_probs.shape[1]
3495
3497
  n_mlp_classes = mlp_probs.shape[1]
3496
- n_classes = max(n_trans_classes, n_mlp_classes)
3498
+
3499
+ has_lstm = lstm_probs is not None
3500
+ if has_lstm:
3501
+ lstm_probs = np.asarray(lstm_probs, dtype=np.float64)
3502
+ if lstm_probs.ndim == 1: lstm_probs = lstm_probs[np.newaxis, :]
3503
+ n_lstm = lstm_probs.shape[1]
3504
+ else:
3505
+ lstm_probs = np.zeros((B, 1), dtype=np.float64) # dummy, not used
3506
+ n_lstm = 0
3507
+
3508
+ n_classes = max(n_trans_classes, n_lstm, n_mlp_classes)
3509
+ print(f"🔄 Aligning classes: trans={n_trans_classes} mlp={n_mlp_classes} "
3510
+ f"lstm={n_lstm} → {n_classes}")
3511
+
3512
+ if isinstance(lstm_weight_hint, (int, float)):
3513
+ lstm_weight_hints = np.full(B, float(lstm_weight_hint), dtype=np.float64)
3514
+ else:
3515
+ lstm_weight_hints = np.asarray(lstm_weight_hint, dtype=np.float64)
3516
+ if lstm_weight_hints.ndim == 0:
3517
+ lstm_weight_hints = np.full(B, float(lstm_weight_hints))
3497
3518
 
3498
3519
  # Create meta features
3499
3520
  meta_features = []
3500
3521
  for i in range(batch_size):
3501
3522
  trans_row = np.zeros(n_classes)
3502
3523
  mlp_row = np.zeros(n_classes)
3503
-
3524
+
3504
3525
  trans_row[:n_trans_classes] = trans_probs[i]
3505
3526
  mlp_row[:n_mlp_classes] = mlp_probs[i]
3506
-
3527
+ if has_lstm:
3528
+ lstm_row = np.zeros(n_classes)
3529
+ lstm_row[:n_lstm] = lstm_probs[i]
3530
+ lstm_row = lstm_row / (lstm_row.sum() + 1e-8)
3531
+
3507
3532
  trans_row = trans_row / (trans_row.sum() + 1e-8)
3508
3533
  mlp_row = mlp_row / (mlp_row.sum() + 1e-8)
3509
3534
 
3510
- features = [
3511
- np.max(trans_row), # Transformer confidence
3512
- np.max(mlp_row), # MLP confidence
3513
- np.std(trans_row), # Transformer spread
3514
- np.std(mlp_row), # MLP spread
3515
- 1.0 if np.argmax(trans_row) == np.argmax(mlp_row) else 0.0, # Agreement
3516
- ]
3517
-
3535
+ if lstm_row is None:
3536
+ features = [
3537
+ np.max(trans_row), # Transformer confidence
3538
+ np.max(mlp_row), # MLP confidence
3539
+ np.std(trans_row), # Transformer spread
3540
+ np.std(mlp_row), # MLP spread
3541
+ 1.0 if np.argmax(trans_row) == np.argmax(mlp_row) else 0.0, # Agreement
3542
+ ]
3543
+ else:
3544
+ features = [
3545
+ np.max(trans_row), # Transformer confidence
3546
+ np.max(mlp_row), # MLP confidence
3547
+ np.max(lstm_row),
3548
+ np.std(trans_row), # Transformer spread
3549
+ np.std(mlp_row), # MLP spread
3550
+ np.std(lstm_row),
3551
+ 1.0 if np.argmax(trans_row) == np.argmax(mlp_row) else 0.0, # Agreement
3552
+ ]
3553
+
3518
3554
  # Add attention stats if available
3519
3555
  if attn_weights is not None and i < len(attn_weights):
3520
3556
  attn = attn_weights[i]
@@ -3540,6 +3576,9 @@ class WeightedEnsemblePredictor:
3540
3576
  mlp_conf = meta_features[i, 1]
3541
3577
  agreement = meta_features[i, 4]
3542
3578
 
3579
+ trans_pred = int(np.argmax(trans_probs[i]))
3580
+ mlp_pred = int(np.argmax(mlp_probs[i]))
3581
+
3543
3582
  # Boost weight when models agree
3544
3583
  base_weight = threshold_feature + AME_sigmoid * agreement
3545
3584
 
@@ -3550,11 +3589,26 @@ class WeightedEnsemblePredictor:
3550
3589
  else:
3551
3590
  trans_weight = 1.0 - base_weight
3552
3591
  mlp_weight = base_weight
3592
+
3593
+ if has_lstm:
3594
+ if lstm_row is None:
3595
+ lstm_row = np.zeros(n_classes)
3596
+ lstm_row[:n_lstm] = lstm_probs[i]
3597
+ lstm_row /= lstm_row.sum() + 1e-8
3553
3598
 
3554
- try:
3555
- ensemble[i] = trans_weight * trans_row + mlp_weight * mlp_row
3556
- except:
3557
- ensemble = trans_weight * trans_row + mlp_weight * mlp_row
3599
+ lstm_pred = int(np.argmax(lstm_probs[i]))
3600
+ la = 1.0 if (lstm_pred == trans_pred or lstm_pred == mlp_pred) \
3601
+ else self.pipeline.confidence_threshold
3602
+ lw = float(lstm_weight_hints[i]) * (1.0 + la) / 2.0
3603
+ total = trans_weight + mlp_weight + lw + 1e-8
3604
+ ensemble[i] = (trans_weight/total) * trans_row + \
3605
+ (mlp_weight/total) * mlp_row + \
3606
+ (lw/total) * lstm_row
3607
+ else:
3608
+ try:
3609
+ ensemble[i] = trans_weight * trans_row + mlp_weight * mlp_row
3610
+ except:
3611
+ ensemble = trans_weight * trans_row + mlp_weight * mlp_row
3558
3612
 
3559
3613
  return ensemble
3560
3614
 
@@ -5962,7 +6016,7 @@ class AsyncMessageQueue:
5962
6016
  async def publish(self, message: Message) -> Any:
5963
6017
  await self._ensure_started()
5964
6018
 
5965
- # plain increment, no lock needed (no await in critical section)
6019
+ # plain increment
5966
6020
  self._counter += 1
5967
6021
  counter = self._counter
5968
6022
 
@@ -12871,117 +12925,272 @@ class AccurateAnswerCache:
12871
12925
 
12872
12926
 
12873
12927
 
12874
-
12875
12928
  class RateLimiter:
12876
- # Token bucket rate limiter
12877
-
12878
- def __init__(self, requests_per_minute: int = 60):
12929
+ """
12930
+ Token bucket rate limiter.
12931
+
12932
+ Supports both:
12933
+ - a single shared bucket (original behavior, backward compatible)
12934
+ - independent per-key buckets (e.g. per peer IP/agent_id),
12935
+ so one noisy peer cannot starve rate-limit capacity from others
12936
+ """
12937
+
12938
+ def __init__(self, requests_per_minute: int = 60, per_key: bool = False,
12939
+ max_keys: int = 1000):
12940
+ #validate config, refuse a limiter that can never refill
12941
+ if requests_per_minute <= 0:
12942
+ raise ValueError(
12943
+ f"[-] requests_per_minute must be > 0, got {requests_per_minute}"
12944
+ )
12945
+
12879
12946
  self.requests_per_minute = requests_per_minute
12880
- self.tokens = requests_per_minute
12881
- self.last_refill = time.time()
12947
+ self.per_key = per_key
12948
+ self.max_keys = max_keys
12882
12949
  self._lock = threading.Lock()
12883
-
12884
- def acquire(self) -> bool:
12950
+
12951
+ if per_key:
12952
+ # one bucket per key, so peers don't share capacity here
12953
+ self._buckets: Dict[str, dict] = {}
12954
+ else:
12955
+ self.tokens = float(requests_per_minute)
12956
+ self.last_refill = time.time()
12957
+
12958
+ def acquire(self, key: str = None) -> bool:
12885
12959
  with self._lock:
12886
- now = time.time()
12887
- # Refill tokens
12888
- elapsed = now - self.last_refill
12889
- new_tokens = elapsed * (self.requests_per_minute / 60)
12890
- self.tokens = min(self.requests_per_minute, self.tokens + new_tokens)
12891
- self.last_refill = now
12892
-
12893
- if self.tokens >= 1:
12894
- self.tokens -= 1
12895
- return True
12896
- return False
12960
+ if self.per_key:
12961
+ return self._acquire_keyed(key or "_default")
12962
+ return self._acquire_global()
12963
+
12964
+ def _acquire_global(self) -> bool:
12965
+ now = time.time()
12966
+ elapsed = now - self.last_refill
12967
+
12968
+ # guarded against clock skew producing negative elapsed
12969
+ if elapsed < 0:
12970
+ logger.warning(
12971
+ f'[!] RateLimiter: system clock moved backward by '
12972
+ f'{-elapsed:.3f}s — ignoring this interval for refill'
12973
+ )
12974
+ elapsed = 0.0
12975
+
12976
+ new_tokens = elapsed * (self.requests_per_minute / 60.0)
12977
+ self.tokens = min(self.requests_per_minute, self.tokens + new_tokens)
12978
+ self.last_refill = now
12979
+
12980
+ if self.tokens >= 1:
12981
+ self.tokens -= 1
12982
+ return True
12983
+ return False
12984
+
12985
+ def _acquire_keyed(self, key: str) -> bool:
12986
+ now = time.time()
12987
+
12988
+ if key not in self._buckets:
12989
+ # cap number of tracked keys to prevent unbounded
12990
+ # growth from an attacker cycling through many fake peer IDs
12991
+ if len(self._buckets) >= self.max_keys:
12992
+ self._evict_oldest_bucket()
12993
+
12994
+ self._buckets[key] = {
12995
+ 'tokens': float(self.requests_per_minute),
12996
+ 'last_refill': now,
12997
+ }
12998
+
12999
+ bucket = self._buckets[key]
13000
+ elapsed = now - bucket['last_refill']
13001
+
13002
+ if elapsed < 0:
13003
+ logger.warning(
13004
+ f'[!] RateLimiter[{key}]: clock moved backward by '
13005
+ f'{-elapsed:.3f}s — ignoring interval'
13006
+ )
13007
+ elapsed = 0.0
13008
+
13009
+ new_tokens = elapsed * (self.requests_per_minute / 60.0)
13010
+ bucket['tokens'] = min(self.requests_per_minute, bucket['tokens'] + new_tokens)
13011
+ bucket['last_refill'] = now
13012
+
13013
+ if bucket['tokens'] >= 1:
13014
+ bucket['tokens'] -= 1
13015
+ return True
13016
+ return False
13017
+
13018
+ def _evict_oldest_bucket(self):
13019
+ """Evict the least-recently-refilled bucket to bound memory use."""
13020
+ if not self._buckets:
13021
+ return
13022
+ oldest_key = min(
13023
+ self._buckets.keys(),
13024
+ key=lambda k: self._buckets[k]['last_refill']
13025
+ )
13026
+ del self._buckets[oldest_key]
13027
+
13028
+ def get_wait_time(self, key: str = None) -> float:
13029
+ """
13030
+ Seconds until at least 1 token will be available.
13031
+ Useful for callers that want to back off.
13032
+ """
13033
+ with self._lock:
13034
+ if self.per_key:
13035
+ bucket = self._buckets.get(key or "_default")
13036
+ tokens = bucket['tokens'] if bucket else self.requests_per_minute
13037
+ else:
13038
+ tokens = self.tokens
13039
+
13040
+ if tokens >= 1:
13041
+ return 0.0
13042
+ tokens_needed = 1 - tokens
13043
+ return tokens_needed / (self.requests_per_minute / 60.0)
13044
+
13045
+ def get_stats(self) -> Dict:
13046
+ """Visibility into limiter state"""
13047
+ with self._lock:
13048
+ if self.per_key:
13049
+ return {
13050
+ 'mode' : 'per_key',
13051
+ 'tracked_keys' : len(self._buckets),
13052
+ 'max_keys' : self.max_keys,
13053
+ 'requests_per_minute': self.requests_per_minute,
13054
+ }
13055
+ return {
13056
+ 'mode' : 'global',
13057
+ 'current_tokens': round(self.tokens, 2),
13058
+ 'requests_per_minute': self.requests_per_minute,
13059
+ }
13060
+
12897
13061
 
12898
13062
  class InputSanitizer:
12899
- # Sanitize and validate inputs
12900
-
13063
+ """Sanitize and validate inputs."""
13064
+
13065
+ # comprehensive control-char stripping including
13066
+ # newline/CR, since this class explicitly exists to protect
13067
+ # logging/serialization from injection
13068
+ _CONTROL_CHARS_PATTERN = re.compile(
13069
+ r'[\x00-\x1f\x7f]'
13070
+ )
13071
+
13072
+ # OPT-IN for genuinely
13073
+ # multi-line legitimate text (e.g. free-form descriptions).
13074
+ _CONTROL_CHARS_ALLOW_WHITESPACE = re.compile(
13075
+ r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'
13076
+ )
13077
+
12901
13078
  @staticmethod
12902
- def sanitize_text(text: str, max_length: int = 10000) -> str:
13079
+ def sanitize_text(text: str, max_length: int = 10000,
13080
+ allow_newlines: bool = False) -> str:
12903
13081
  if not isinstance(text, str):
12904
13082
  raise SecurityError("[-] Input must be a string")
12905
-
12906
- # RemovING null bytes and control characters
12907
- text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
12908
-
12909
- # Limit length
13083
+
13084
+ # check length BEFORE any transformation, so length
13085
+ # limits reflect what was actually submitted.
12910
13086
  if len(text) > max_length:
12911
13087
  raise SecurityError(f"[-] Text exceeds maximum length of {max_length}")
12912
-
12913
- # Remove potential injection patterns (for logging/serialization)
12914
- dangerous_patterns = [
12915
- r'[\x00-\x08\x0b\x0c\x0e-\x1f]', # Control chars
12916
- r'\\x[0-9a-fA-F]{2}', # Hex escapes
12917
- r'\\u[0-9a-fA-F]{4}', # Unicode escapes
13088
+
13089
+ if allow_newlines:
13090
+ text = InputSanitizer._CONTROL_CHARS_ALLOW_WHITESPACE.sub('', text)
13091
+ # even when newlines are allowed, escape them for log safety
13092
+ else:
13093
+ text = InputSanitizer._CONTROL_CHARS_PATTERN.sub('', text)
13094
+
13095
+ escape_patterns = [
13096
+ r'\\x[0-9a-fA-F]{2}', # literal "\x41" style escapes
13097
+ r'\\u[0-9a-fA-F]{4}', # literal "\u0041" style escapes
12918
13098
  ]
12919
-
12920
- for pattern in dangerous_patterns:
13099
+ for pattern in escape_patterns:
12921
13100
  if re.search(pattern, text):
12922
- # Log but don't block - just escape
13101
+ logger.warning(
13102
+ f'[!] sanitize_text: literal escape sequence pattern '
13103
+ f'detected and neutralized (pattern={pattern})'
13104
+ )
12923
13105
  text = re.sub(pattern, '?', text)
12924
-
13106
+
12925
13107
  return text.strip()
12926
-
13108
+
12927
13109
  @staticmethod
12928
13110
  def validate_batch_size(size: int, max_batch: int = 100) -> bool:
13111
+ # validated type before comparison to avoid a confusing
13112
+ # TypeError if a non-int slips through
13113
+ if not isinstance(size, int):
13114
+ raise SecurityError(f"[-] Batch size must be an integer, got {type(size).__name__}")
12929
13115
  if size <= 0 or size > max_batch:
12930
13116
  raise SecurityError(f"[-] Batch size must be between 1 and {max_batch}")
12931
13117
  return True
12932
13118
 
13119
+
12933
13120
  class ApiKeyManager:
12934
- # Manage API keys with rotation
12935
-
12936
- def __init__(self, rotation_days: int = 30):
12937
- self.keys: Dict[str, dict] = {} # key_hash -> {created_at, last_used, metadata}
13121
+ """Manage API keys with rotation and bounded storage."""
13122
+
13123
+ MIN_KEY_LENGTH = 16 # minimum acceptable length for caller-supplied keys
13124
+
13125
+ def __init__(self, rotation_days: int = 30, max_keys: int = 10000):
13126
+ self.keys: Dict[str, dict] = {}
12938
13127
  self.rotation_days = rotation_days
13128
+ self.max_keys = max_keys
12939
13129
  self._lock = threading.Lock()
12940
-
13130
+
12941
13131
  def generate_key(self, metadata: dict = None, key_value: str = None) -> str:
12942
- # Generate a new API key
12943
13132
  if key_value:
12944
- # Use provided key value
13133
+ # reject obviously weak caller-supplied keys
13134
+ if not isinstance(key_value, str) or len(key_value) < self.MIN_KEY_LENGTH:
13135
+ raise SecurityError(
13136
+ f"[-] Provided key must be a string of at least "
13137
+ f"{self.MIN_KEY_LENGTH} characters"
13138
+ )
12945
13139
  raw_key = key_value
12946
13140
  else:
12947
- # Generate random key
12948
- raw_key = secrets.token_urlsafe(32)
13141
+ raw_key = secrets.token_urlsafe(32)
12949
13142
 
12950
13143
  key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
12951
-
13144
+
12952
13145
  with self._lock:
13146
+ # warn on collision
13147
+ if key_hash in self.keys and self.keys[key_hash].get('is_active'):
13148
+ logger.warning(
13149
+ f'[!] generate_key: key hash collision with an '
13150
+ f'already-active key — overwriting metadata'
13151
+ )
13152
+
13153
+ # evict oldest inactive entries if at capacity,
13154
+ if len(self.keys) >= self.max_keys:
13155
+ self._evict_oldest_inactive()
13156
+
12953
13157
  self.keys[key_hash] = {
12954
13158
  'created_at': datetime.now(),
12955
- 'last_used': None,
12956
- 'metadata': metadata or {},
12957
- 'is_active': True
13159
+ 'last_used' : None,
13160
+ 'metadata' : metadata or {},
13161
+ 'is_active' : True
12958
13162
  }
12959
-
13163
+
12960
13164
  return raw_key
12961
-
13165
+
12962
13166
  def validate_key(self, api_key: str) -> bool:
12963
- # Validate an API key
12964
- if not api_key:
13167
+ # explicit type check before .encode(), avoids an
13168
+ # uncontrolled AttributeError on non-string input
13169
+ if not api_key or not isinstance(api_key, str):
12965
13170
  return False
12966
-
12967
- key_hash = hashlib.sha256(api_key.encode()).hexdigest()
12968
-
13171
+
13172
+ try:
13173
+ key_hash = hashlib.sha256(api_key.encode()).hexdigest()
13174
+ except (UnicodeEncodeError, AttributeError):
13175
+ return False
13176
+
12969
13177
  with self._lock:
12970
13178
  key_info = self.keys.get(key_hash)
12971
13179
  if not key_info or not key_info.get('is_active', False):
12972
13180
  return False
12973
-
12974
- # Check rotation
13181
+
12975
13182
  age = (datetime.now() - key_info['created_at']).days
12976
13183
  if age >= self.rotation_days:
12977
13184
  key_info['is_active'] = False
12978
13185
  return False
12979
-
13186
+
12980
13187
  key_info['last_used'] = datetime.now()
12981
13188
  return True
12982
-
13189
+
12983
13190
  def revoke_key(self, api_key: str) -> bool:
12984
- # Revoke an API key
13191
+ if not api_key or not isinstance(api_key, str):
13192
+ return False
13193
+
12985
13194
  key_hash = hashlib.sha256(api_key.encode()).hexdigest()
12986
13195
  with self._lock:
12987
13196
  if key_hash in self.keys:
@@ -12989,6 +13198,54 @@ class ApiKeyManager:
12989
13198
  return True
12990
13199
  return False
12991
13200
 
13201
+ def cleanup_expired(self, grace_period_days: int = 7):
13202
+ """
13203
+ periodic sweep to actually remove long-inactive keys.
13204
+ """
13205
+ with self._lock:
13206
+ now = datetime.now()
13207
+ to_remove = [
13208
+ key_hash for key_hash, info in self.keys.items()
13209
+ if not info.get('is_active', False)
13210
+ and (now - info['created_at']).days >= (self.rotation_days + grace_period_days)
13211
+ ]
13212
+ for key_hash in to_remove:
13213
+ del self.keys[key_hash]
13214
+
13215
+ if to_remove:
13216
+ logger.info(f'[=] Cleaned up {len(to_remove)} expired API key entries')
13217
+
13218
+ return len(to_remove)
13219
+
13220
+ def _evict_oldest_inactive(self):
13221
+ """Evict the oldest inactive key to make room, called under lock."""
13222
+ inactive = [
13223
+ (h, info) for h, info in self.keys.items()
13224
+ if not info.get('is_active', False)
13225
+ ]
13226
+ if inactive:
13227
+ oldest_hash = min(inactive, key=lambda x: x[1]['created_at'])[0]
13228
+ del self.keys[oldest_hash]
13229
+ else:
13230
+ # no inactive keys to evict and still at capacity
13231
+ logger.warning(
13232
+ f'[!] ApiKeyManager at max_keys={self.max_keys} capacity '
13233
+ f'with no inactive keys to evict — consider raising max_keys '
13234
+ f'or auditing why so many keys remain active'
13235
+ )
13236
+
13237
+ def get_stats(self) -> Dict:
13238
+ """Visibility into key store health — same pattern as WorkerPool.get_health()."""
13239
+ with self._lock:
13240
+ active = sum(1 for k in self.keys.values() if k.get('is_active'))
13241
+ inactive = len(self.keys) - active
13242
+ return {
13243
+ 'total_keys' : len(self.keys),
13244
+ 'active_keys' : active,
13245
+ 'inactive_keys': inactive,
13246
+ 'at_capacity' : len(self.keys) >= self.max_keys,
13247
+ }
13248
+
12992
13249
  class AsyncResultQueue:
12993
13250
  """
12994
13251
  Complete result queue with integrated processor.
@@ -15474,7 +15731,7 @@ class PipelinePredictionManager:
15474
15731
  if titles is not None and rules is not None:
15475
15732
  print(f"[🔍] Preparing data for {len(titles)} titles with {len(rules)} length of rules.")
15476
15733
  if X is None and y is None or X is None or y is None:
15477
- print('[🔄] Creating automatic X samples because X is not provided manually.')
15734
+ print('[🔄] Creating automatic X samples because X Samples is not provided manually.')
15478
15735
  datasets, X_gen = self.pipeline.data_preparation(titles, label_map)
15479
15736
  _, y, _, _ = self.pipeline.mlp_training_features(rules, datasets)
15480
15737
  else:
@@ -15735,7 +15992,7 @@ class PipelinePredictionManager:
15735
15992
  num_classes = num_classes or mlp_probs.shape[1]
15736
15993
  models_agree = False
15737
15994
 
15738
- # ── MLP ─────────────────────────────────────────────
15995
+ # ── MLP layer ─────────────────────────────────────────────
15739
15996
  mlp_class_idx = int(np.argmax(mlp_probs[i]))
15740
15997
  is_valid_index = 0 <= mlp_class_idx < num_classes
15741
15998
 
@@ -15891,6 +16148,7 @@ class PipelinePredictionManager:
15891
16148
  use_transformer=True, return_attention=False,
15892
16149
  save_results=True, batch_size=2):
15893
16150
  try:
16151
+ # ____ init temporary layer ____
15894
16152
  eps = 1e-5
15895
16153
  trans_probs = None
15896
16154
  attn_weights = None
@@ -15993,6 +16251,7 @@ class PipelinePredictionManager:
15993
16251
 
15994
16252
  if X is None or len(X) == 0 or isinstance(X, int) or (isinstance(X, np.ndarray) and X.size == 0):
15995
16253
  # Get MLP predictions
16254
+ titles = None
15996
16255
  if titles is not None and len(titles) > 0:
15997
16256
  if isinstance(titles[0], tuple):
15998
16257
  mlp_titles = [t[0] for t in titles]
@@ -16001,8 +16260,16 @@ class PipelinePredictionManager:
16001
16260
 
16002
16261
  if not hasattr(self.pipeline, 'tfidf') or self.pipeline.tfidf is None:
16003
16262
  self.pipeline.initialize_fitting(mlp_titles)
16004
-
16005
- X = self.pipeline.tfidf.transform(mlp_titles).toarray()
16263
+
16264
+ if isinstance(mlp_titles, (list, tuple, np.ndarray)):
16265
+ titles = mlp_titles[0]
16266
+ if isinstance(mlp_titles, (list, tuple, np.ndarray)):
16267
+ titles = titles[0]
16268
+
16269
+ if not isinstance(mlp_titles, list) or isinstance(titles, str):
16270
+ if titles is not None:
16271
+ mlp_titles = titles
16272
+ X = self.pipeline.tfidf.transform(mlp_titles).toarray()
16006
16273
 
16007
16274
  if X_gen is not None:
16008
16275
  X = X_gen
@@ -16336,9 +16603,19 @@ class PipelinePredictionManager:
16336
16603
  }
16337
16604
  results.append(result)
16338
16605
 
16339
- final_probs = results[0]['final_probs'] if results else None
16340
- final_class_idx = results[0]['predicted_idx'] if results else None
16341
- agreement = results[0]['models_agree'] if results else None
16606
+ if results is not None and isinstance(results[0], dict):
16607
+ try:
16608
+ final_probs = results[0]['final_probs'] if results else None
16609
+ final_class_idx = results[0]['predicted_idx'] if results else None
16610
+ agreement = results[0]['models_agree'] if results else None
16611
+ except:
16612
+ final_probs = results[0].get('final_probs', None)
16613
+ final_class_idx = results[0].get('predicted_idx', None)
16614
+ agreement = results[0].get('models_agree', None)
16615
+ else:
16616
+ final_probs = mlp_probs
16617
+ final_class_idx = target_pred_indices
16618
+ agreement = False
16342
16619
 
16343
16620
  # Display results
16344
16621
  verbose = False