-
Notifications
You must be signed in to change notification settings - Fork 1
fix: [no-ticket] harbor - access-tier integrity (free-baseline privilege, k-anonymity floor, trusted nested CLI) #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: harbor-6-selection-integrity
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,11 @@ class SubmitDisabledError(RuntimeError): | |
| """Raised when submit() is called but the task does not use submit selection.""" | ||
|
|
||
|
|
||
| class KAnonymityError(RuntimeError): | ||
| """Raised when an agent eval on a non_viewable split selects fewer samples | ||
| than the k-anonymity floor allows.""" | ||
|
|
||
|
|
||
| class EvaluationSidecar: | ||
| """Agent-facing handlers over the EvaluationEngine. | ||
|
|
||
|
|
@@ -56,6 +61,7 @@ def __init__( | |
| admin_volume: Path, | ||
| submit_enabled: bool = False, | ||
| base_commit: str | None = None, | ||
| k_anonymity_floor: int = 5, | ||
| ): | ||
| self.engine = engine | ||
| self.split_accesses = split_accesses | ||
|
|
@@ -64,13 +70,41 @@ def __init__( | |
| self.admin_volume = Path(admin_volume) | ||
| self.submit_enabled = submit_enabled | ||
| self.base_commit = base_commit | ||
| # Minimum sample count for an agent-chosen SUBSET eval of a non_viewable | ||
| # split. The aggregate response carries mean_score, so a singleton subset | ||
| # returns the sample's label-derived score verbatim, and n singleton | ||
| # evals reconstruct the split's per-sample labels wholesale. The floor | ||
| # applies only to proper subsets (sample_ids/num_samples): a full-split | ||
| # eval reveals exactly the intended aggregate, so it always passes and a | ||
| # split smaller than the floor degrades to full-split-only rather than | ||
| # becoming unevaluable. This is a cost multiplier, not a proof: means of | ||
| # k-sized overlapping subsets still admit reconstruction by elimination, | ||
| # but at >= k times the sample budget per label. <= 1 disables the floor. | ||
| self.k_anonymity_floor = k_anonymity_floor | ||
| self._free_baseline_used = False | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Handlers (the HTTP layer resolves `admin` from auth and calls these) | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| async def evaluate(self, req: EvalRequest, *, admin: bool = False) -> EvalSummary: | ||
| # k-anonymity floor, checked before any work (no commit transfer, no | ||
| # budget debit, no eval) so a rejected request costs the agent nothing. | ||
| # sample_ids is None exactly when the request covers the full split | ||
| # (resolve_samples collapses a covering num_samples to None too), and a | ||
| # full-split aggregate is the intended surface, so only proper subsets | ||
| # are floored. | ||
| if not admin and self.k_anonymity_floor > 1: | ||
| tier = tier_for_split(req.split, self.split_accesses) | ||
| if tier == SplitAccessLevel.non_viewable: | ||
| sample_ids, n = self.engine.resolve_samples(req) | ||
| if sample_ids is not None and n < self.k_anonymity_floor: | ||
| raise KAnonymityError( | ||
| f"Evals on non_viewable split '{req.split}' must cover " | ||
| f"at least {self.k_anonymity_floor} samples (or the " | ||
| f"whole split); got {n}. Aggregate scores over smaller " | ||
| f"subsets would reveal per-sample results." | ||
| ) | ||
| sha = await self._transfer_commit(req.commit) | ||
| # The agent's FIRST eval of the seeded baseline is budget-free. The | ||
| # baseline is the reference every candidate is implicitly compared to, | ||
|
|
@@ -80,17 +114,23 @@ async def evaluate(self, req: EvalRequest, *, admin: bool = False) -> EvalSummar | |
| # an optimizer that skipped the reference could not tell a no-op edit | ||
| # from an improvement and quit with budget unspent). Capped at one: | ||
| # later baseline evals debit normally, so free compute is bounded. | ||
| # `free` waives only the budget debit; the eval still runs as the agent | ||
| # (tier gates apply), so the free baseline cannot touch no_access | ||
| # splits — riding the admin flag here did exactly that. | ||
| free_baseline = ( | ||
| not admin | ||
| and self.base_commit is not None | ||
| and sha == self.base_commit | ||
| and not self._free_baseline_used | ||
| ) | ||
| if free_baseline: | ||
| self._free_baseline_used = True | ||
| exp = await self.engine.evaluate( | ||
| replace(req, commit=sha), admin=admin or free_baseline | ||
| replace(req, commit=sha), admin=admin, free=free_baseline | ||
| ) | ||
| # Consume the freebie only after the eval succeeded: an eval that | ||
| # raised (invalid split, infra failure) has given the agent nothing, | ||
| # so it must not burn the one free reference measurement. | ||
| if free_baseline: | ||
| self._free_baseline_used = True | ||
|
Comment on lines
120
to
+133
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Moving The safer fix is to optimistically set the flag before the eval and restore it on failure: if free_baseline:
self._free_baseline_used = True # claim before any await
try:
exp = await self.engine.evaluate(
replace(req, commit=sha), admin=admin, free=free_baseline
)
except Exception:
if free_baseline:
self._free_baseline_used = False # restore so a retry is still free
raisePrompt To Fix With AIThis is a comment left during a code review.
Path: vero/src/vero/harbor/server.py
Line: 120-133
Comment:
**Race window on `_free_baseline_used` flag**
Moving `_free_baseline_used = True` to after the `await engine.evaluate` call reintroduces a race window. In asyncio, execution is synchronous between `await` points, so the old code (which set the flag **before** the eval await) was effectively atomic: once A resumed from `_transfer_commit` and set the flag, any concurrent B that resumed later would see `_free_baseline_used = True`. Now the flag isn't set until after the full eval completes, so two concurrent requests that both finish `_transfer_commit` will both read `_free_baseline_used = False`, both set `free_baseline = True`, and both call `engine.evaluate(..., free=True)` — giving the agent two free baseline evals.
The safer fix is to optimistically set the flag before the eval and restore it on failure:
```python
if free_baseline:
self._free_baseline_used = True # claim before any await
try:
exp = await self.engine.evaluate(
replace(req, commit=sha), admin=admin, free=free_baseline
)
except Exception:
if free_baseline:
self._free_baseline_used = False # restore so a retry is still free
raise
```
How can I resolve this? If you propose a fix, please make it concise.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, and you are right about the asyncio interleaving. Fixed in e1b1d6b (stack tip, harbor-9-ops): the flag is claimed BEFORE the eval await (so a concurrent second baseline eval sees the claim and pays) and refunded in an except block if the eval raises, which keeps the property this PR wanted (a failed eval does not burn the freebie). Regression test: |
||
| # Route with the agent's real tier even when the eval was unmetered. | ||
| result_path = self._route_results(exp, admin=admin) | ||
| budget_remaining = None | ||
|
|
@@ -125,6 +165,7 @@ def status(self) -> StatusSummary: | |
| free_baseline_available=( | ||
| self.base_commit is not None and not self._free_baseline_used | ||
| ), | ||
| k_anonymity_floor=self.k_anonymity_floor, | ||
| ) | ||
|
|
||
| def list_experiments(self) -> list[dict]: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
build_statusdefaultsk_anonymity_floorto1(the "disabled" sentinel), whileEvaluationSidecardefaults to5. Any caller that forgets to pass the floor will advertisemin_subset_samples = 1fornon_viewablesplits, causing agents to send sub-floor requests that the sidecar then rejects withKAnonymityError. Defaulting to5— the same asEvaluationSidecar.__init__— keeps the function's own default consistent with the enforcement default.Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed; fixed in e1b1d6b (stack tip):
build_statusnow defaultsk_anonymity_floor=5, matching the sidecar's enforcement default, with a comment explaining why the two defaults must not drift. Test:test_default_floor_matches_sidecar_enforcement_default.