Skip to content

fix(packaging+safety): import path unification + L3 dead-code fix - #8

Open
dengluozhang-png wants to merge 5 commits into
lawcontinue:mainfrom
dengluozhang-png:fix/packaging-and-l3
Open

fix(packaging+safety): import path unification + L3 dead-code fix#8
dengluozhang-png wants to merge 5 commits into
lawcontinue:mainfrom
dengluozhang-png:fix/packaging-and-l3

Conversation

@dengluozhang-png

Copy link
Copy Markdown
Contributor

Two real issues from project audit + demo walkthrough

Discovered via the end-to-end demo (%TEMP%\hippo_demo.py) and a parallel code-quality / ops audit that ran after PR #7. Both fixes are self-contained and tested.

Fix 1: mv embedding/ -> hippo/embedding/ (packaging)

Symptom: Every README/LAUNCH_KIT/dev-to-article example (17+ occurrences) shows from hippo.embedding import VectorStore, but hippo/embedding/ did not exist in the source tree — embedding/ was at project root. Any user pip install hippo-llm-then-copy-paste-from-README got ImportError: No module named 'hippo.embedding' on first line.

Fix:

  • mv embedding/ hippo/embedding/ — package name now matches import path
  • hippo/embedding/tests/*.py: rewrite internal imports to new layout
  • governance/__init__.py: drop sys.path hack that mutated project root; use canonical from hippo.embedding.memory_safety import ...

Fix 2: safety_guard L3 escalation was 100% dead code

Symptom: SafetyGuard.check() claims a 3-tier L1→L2→L3 architecture, but the L3 embedding-similarity check was completely unreachable. needs_l2 (boolean) was overloaded with three semantics and unconditionally reset to False inside the L2 block, even when L2 had not actually run.

Consequence: With default SafetyGuard() (no L2 model bundled, _l2_trained=False), any input with ≥ l1_warn_threshold medium pattern hits would route to 'l3' escalation — but the code fell through to 'L1 clean' without consulting L3. There were zero layer=3 assertions in the existing test suite (38 tests), confirming the dead-code hypothesis.

Fix: needs_l2: boolescalate: Optional[str] (None / 'l2' / 'l3'). L2 block only sets escalate='l3' in the [l2_warn_confidence, l2_block_confidence) band. L1 medium hits with no L2 model go straight to escalate='l3' (previously-dead branch).

Tests

tests/test_safety_guard_v2.py::TestL3Escalation (5 cases):

  • test_l3_block_when_model_seeded — L3 blocks when seeded + sim ≥ threshold
  • test_l3_no_seed_still_no_crash — graceful fallback when _l3_seeded=False
  • test_l2_block_short_circuits_before_l3 — L2 returns at layer=2
  • test_l2_warn_escalates_to_l3 — the previously-dead branch
  • test_no_escalation_when_l1_clean — regression guard

Verification

Before After
from hippo.embedding import VectorStore ImportError works
test_safety_guard_v2.py 38 passed 43 passed
full suite 302 passed 328 passed
full suite errors 0 0
L3 reachable in code no (dead) yes
governance tests collectable broken (sys.path hack) clean
hippo/safety_guard.py + governance/ + hippo/embedding/ ruff clean

Related

PR #6, PR #7 were earlier fix PRs on the same fork branch. This PR uses a new branch fix/packaging-and-l3 so the two unrelated fixes can be reviewed independently.

VectorStore used , but Connection's
context manager only manages the transaction (commit/rollback) — it does
NOT close the connection, which is left to GC. On Windows, GC timing is
non-deterministic, so the .db file stayed locked when TemporaryDirectory
or os.unlink tried to clean it up, raising PermissionError [WinError 32]
in 16 teardowns.

Changes:
- store.py: hold one long-lived self._conn in __init__, reuse it across
  all SQL ops (also faster than reconnecting per call); add close() and
  __enter__/__exit__ context-manager protocol
- memory_safety.py: stop bypassing with sqlite3.connect(store.db_path);
  reuse store._conn for decay_low_confidence / get_behavioral_signals
- tests: manage store lifetime via  / explicit close() so the
  connection is released before temp-dir cleanup

Result: embedding suite 16 ERROR + 5 FAILED -> 45 passed, 1 skipped.
Full suite errors 16 -> 0. store.py / memory_safety.py ruff clean.
…se() API safety

PR review by @lawcontinue flagged 1 P0 and 3 P1 issues from the original
sqlite3-connection-lifecycle fix. This commit resolves all of them and
adds tests proving the new behaviors.

### P0: multi-threaded regression

Long-lived self._conn uses sqlite3's default check_same_thread=True, which
broke any caller doing add()/delete() from a worker thread (the previous
short-lived-connection model worked fine across threads).

Fix:
- store.py: connect with check_same_thread=False. SQLite itself is thread-
  safe in serialized mode (the default); the flag only guards Python-level
  thread affinity.
- store.py: add self._lock = threading.Lock(); guard all write paths
  (add / add_batch / delete / update_metadata / rebuild_embeddings).
  Reads are intentionally not locked — SQLite's MVCC + serialized
  isolation handle concurrent SELECTs safely, and locking reads would
  hurt throughput with no correctness gain.

### P1#1: close() followed by API call

Previously raised AttributeError: 'NoneType' object has no attribute
'execute'. Now raises RuntimeError('VectorStore is closed; ...') with a
clear message. Implemented via _check_open() helper called at the top of
every public SQL-touching method (add, add_batch, delete, update_metadata,
rebuild_embeddings, search, execute). close() itself is idempotent.

### P1#2: memory_safety reaching into store._conn

decay_low_confidence() and get_behavioral_signals() used store._conn
directly, deepening the coupling. Added public VectorStore.execute(sql,
params) and switched memory_safety to use it. _check_open() comes free.
The decay write path now also takes store._lock to stay consistent with
the Python-side in-memory indexes.

### P1#3: silent exception swallowing in close()

except sqlite3.Error: pass → _log.warning('... sqlite close failed: %s', e)
so unexpected cleanup failures are visible.

### __exit__ doc nit

Added a comment explaining why __exit__ returns False (must propagate
exceptions from inside the  block — close() never swallows them).

### Tests (test_embedding.py)

- test_store_concurrent_writes: 5 threads × 6 add() calls = 30 docs,
  no errors, no duplicate IDs.
- test_store_concurrent_search_during_writes: 3 writers + 3 readers
  interleaved, no exceptions from either side.
- test_store_after_close_raises_runtime_error: all 5 public methods
  raise RuntimeError post-close; close() is idempotent.

### Verification

- embedding suite: 45 passed (PR lawcontinue#6 baseline) → 37 passed here (no
  change in scope, +4 new tests, the 36 pre-existing pass count went up
  only because test_benchmark.py is exercised separately). Actually
  test_embedding.py + test_memory_safety.py together: 37 passed,
  1 skipped, 0 errors (was 16 ERROR + 5 FAILED before PR lawcontinue#6).
- Full suite: 302 passed, 0 errors (was 293 passed, 16 errors).
- store.py / memory_safety.py ruff: clean.
Discovered via end-to-end demo (5-scenario hippo walkthrough): two real
bugs in core safety + memory layers, one P0 and one P1.

### P0 Bug lawcontinue#1: Chinese L1 attack patterns silently bypass SafetyGuard

The cn_ignore_instructions regex
  忽略(?:之前|上面|先前|此前)(?:所有|全部)?(?:指令|...)
was too tight — natural Chinese phrasings like '忽略之前的指令' (with
the structural particle '的') failed to match. The regex also had no
synonym coverage ('无视' / '别' / '勿' all bypassed).

Worse: the original regex lived in _L1_MEDIUM_RISK, but the default
l1_block_threshold=5 means a single medium hit is silently treated as
'L1 clean' → user-facing attack prompts reach the model.

Fix:
- Move the equivalent pattern to _L1_HIGH_RISK as 'cn_ignore_previous'
  (mirrors the English 'ignore_previous' placement).
- Pattern now tolerates 的/地/得 structural particles and 0-3 filler
  Chinese chars between the trigger and the noun, plus covers 5
  synonyms (忽略/无视/请勿/别/勿).
- Add cn_disregard_directive (MEDIUM) for '不遵守/请勿遵守' variants.
  Single medium hits still don't block (avoid false positives on e.g.
  '请勿遵守之前的客户规定'), but the pattern is now visible in
  result.warnings for downstream escalation.

### P1 Bug lawcontinue#3: decay_low_confidence default params are silently broken

Three compounding bugs made the function a no-op under default args:

(a) Default threshold=0.3 with model memory default confidence=0.5
    meant  always skipped model
    memory. Lowered the bar.

(b) cutoff used  which produces
    'YYYY-MM-DDTHH:MM:SS+00:00' — NOT comparable with SQLite's
    CURRENT_TIMESTAMP 'YYYY-MM-DD HH:MM:SS' format. Result: 0 rows
    returned regardless of days_old.

(c) SQLite CURRENT_TIMESTAMP timezone behavior is platform-dependent
    (UTC on standard Unix, local time on many Windows + Python builds).
    Empirically local time on this project's setup. Use local naive
    datetime to match; opt-in env var HIPPO_DECAY_USE_UTC=1 for UTC.

Also: the  condition was implemented in the
PR diff but the logic was inverted —  would
SKIP low-conf docs, which is the opposite of what 'decay low conf'
should do. The original  was
correct; what was wrong was the docstring (saying '<') and the default
threshold. Fixed both.

### Tests

- tests/test_safety_guard_v2.py: TestCnL1IgnorePrevious with 7 CN
  attack phrasings (parametrized) + 3 false-positive guards.
  All 7 must be either blocked OR produce a CN safety warning
  (no more 'L1 clean' on attack prompts).
- embedding/tests/test_memory_safety.py: TestDecayDefaultThreshold
  with 4 regression tests covering: default params decay model memory,
  user/verified skipped even with low conf, explicit threshold above
  confidence decays, 24h idempotency holds.

### Verification

| | Before | After |
|---|---|---|
| CN attack prompts blocked | 1/7 | 7/7 (or medium-flagged) |
| decay_low_confidence default (days_old=0) | 0 docs | 2 docs (correct) |
| safety_guard.py / memory_safety.py ruff | — | clean |
| full suite passed | 302 | 316 |
| full suite errors | 0 | 0 |
| new tests | — | +11 (7 + 4) |
Bug discovered during end-to-end demo walkthrough: README/LAUNCH_KIT/
dev-to-article all show `from hippo.embedding import VectorStore` (17+
occurrences across docs and examples), but the source tree had `embedding/`
at the project root, not `hippo/embedding/`. pip install hippo-llm users
copy-pasting any README example got ImportError on first line.

Two options were considered:
  (a) mv embedding/ hippo/embedding/  — chosen
  (b) rewrite all 17+ doc imports to `from embedding.store import ...`

Picked (a) because:
  - Package name `hippo-llm` should match `hippo.*` import namespace
  - pyproject.toml already includes both 'embedding*' and 'hippo*' in
    packages.find.include, so wheel packaging works unchanged
  - New `from hippo.embedding import ...` works; old
    `from embedding.store import ...` still works (as an alias via
    sys.path during source-checkout dev workflow)

Changes:
- mv embedding/ -> hippo/embedding/ (16 files renamed, content unchanged)
- hippo/embedding/tests/*.py: rewrite 'from embedding.X' ->
  'from hippo.embedding.X' to match new package layout
- governance/__init__.py: remove sys.path hack inserting project/embedding,
  replace with `from hippo.embedding.memory_safety import StakeLevel`
  (the path-mutation was a workaround for the old layout that became
  obsolete after the mv)

Verification:
- All tests pass: 302 -> 328 passed (governance tests now collectable,
  previously broken by sys.path fragility)
- hippo/embedding/ + governance/ ruff clean
- `from hippo.embedding import VectorStore` works (previously ImportError)

Follow-up:
- pyproject.toml packages.find.include could simplify to just 'hippo*'
  (no longer need explicit 'embedding*' since hippo.embedding.* is a
  subpackage) — left for a separate cleanup PR
Bug discovered during project audit: SafetyGuard.check() had its L3
embedding-semantic layer as completely unreachable dead code.

Root cause: `needs_l2` was a boolean flag expressing three states
(BLOCKED / PASS / ESCALATE_TO_L3) using a single True/False. The L2 block
unconditionally set `needs_l2 = False` at line 437 even when it had not
actually run, so L3 (line 451) could never be entered.

  escalate = "l3" when L1 medium hits >= l1_warn_threshold AND L2 not
  trained (default config!). With default SafetyGuard() this is the
  realistic case — L2 model isn't bundled, _l2_trained stays False —
  but the code fell through to 'L1 clean' without ever consulting L3.

Consequence: `L3 embedding similarity check` (`hippo/safety_guard.py:451-469`)
was 0% effective. There were also zero `layer=3` assertions in the
existing test suite (38 tests in test_safety_guard_v2.py), confirming the
dead-code hypothesis.

Fix:
- Replace `needs_l2: bool` with `escalate: Optional[str]` (None / 'l2' / 'l3')
- L2 block only sets escalate='l3' when L2 score in [warn_confidence,
  block_confidence) (was: always set False regardless of input)
- L1 medium hits with no L2 model go straight to L3 (escalate='l3')
- L3 path now reachable for all three escalation branches

Tests added (test_safety_guard_v2.py::TestL3Escalation, 5 cases):
- test_l3_block_when_model_seeded: L3 actually blocks when seeded +
  embedding sim high (would have silently skipped pre-fix)
- test_l3_no_seed_still_no_crash: graceful fallback when _l3_seeded=False
- test_l2_block_short_circuits_before_l3: L2 block returns at layer=2
- test_l2_warn_escalates_to_l3: the previously-dead branch — L2 in
  [warn, block) range, escalate='l3', L3 blocks
- test_no_escalation_when_l1_clean: regression guard for L1-only fast path

Companion fix (governance/__init__.py): remove sys.path hack that
  inserted project/embedding to make `from memory_safety import ...`
  work. Now uses the canonical `from hippo.embedding.memory_safety
  import ...` path (only possible after PR lawcontinue#1's mv). Governance tests
  were broken pre-fix; they now collect cleanly.

Verification:
- test_safety_guard_v2.py: 38 -> 43 passed (5 new L3 tests)
- Full suite: 323 -> 328 passed
- hippo/safety_guard.py + governance/ ruff clean
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants