@loopover/engine 1.0.0
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.
- package/CHANGELOG.md +94 -0
- package/LICENSE +661 -0
- package/README.md +676 -0
- package/dist/advisory/gate-advisory.d.ts +151 -0
- package/dist/advisory/gate-advisory.js +512 -0
- package/dist/ai-policy-map.d.ts +97 -0
- package/dist/ai-policy-map.js +377 -0
- package/dist/ams-policy-spec.d.ts +56 -0
- package/dist/ams-policy-spec.js +165 -0
- package/dist/calibration-dashboard.d.ts +23 -0
- package/dist/calibration-dashboard.js +65 -0
- package/dist/calibration-trend.d.ts +40 -0
- package/dist/calibration-trend.js +60 -0
- package/dist/contributor-fit.d.ts +24 -0
- package/dist/contributor-fit.js +50 -0
- package/dist/discovery-index-contract.d.ts +74 -0
- package/dist/discovery-index-contract.js +184 -0
- package/dist/discovery-soft-claim.d.ts +35 -0
- package/dist/discovery-soft-claim.js +50 -0
- package/dist/duplicate-winner.d.ts +57 -0
- package/dist/duplicate-winner.js +98 -0
- package/dist/feasibility.d.ts +27 -0
- package/dist/feasibility.js +86 -0
- package/dist/finding-severity-calibration.d.ts +116 -0
- package/dist/finding-severity-calibration.js +457 -0
- package/dist/fleet-run-manifest.d.ts +41 -0
- package/dist/fleet-run-manifest.js +151 -0
- package/dist/focus-manifest/guidance.d.ts +21 -0
- package/dist/focus-manifest/guidance.js +222 -0
- package/dist/focus-manifest.d.ts +826 -0
- package/dist/focus-manifest.js +2730 -0
- package/dist/gate-verdict-calibration.d.ts +111 -0
- package/dist/gate-verdict-calibration.js +411 -0
- package/dist/github/constants.d.ts +1 -0
- package/dist/github/constants.js +1 -0
- package/dist/github/sanitize-public-comment.d.ts +1 -0
- package/dist/github/sanitize-public-comment.js +26 -0
- package/dist/goal-model.d.ts +7 -0
- package/dist/goal-model.js +87 -0
- package/dist/governor/action-mode.d.ts +55 -0
- package/dist/governor/action-mode.js +74 -0
- package/dist/governor/budget-cap.d.ts +49 -0
- package/dist/governor/budget-cap.js +34 -0
- package/dist/governor/chokepoint.d.ts +58 -0
- package/dist/governor/chokepoint.js +269 -0
- package/dist/governor/kill-switch.d.ts +38 -0
- package/dist/governor/kill-switch.js +62 -0
- package/dist/governor/rate-limit.d.ts +40 -0
- package/dist/governor/rate-limit.js +63 -0
- package/dist/governor/reputation-throttle.d.ts +50 -0
- package/dist/governor/reputation-throttle.js +94 -0
- package/dist/governor/run-halt.d.ts +39 -0
- package/dist/governor/run-halt.js +83 -0
- package/dist/governor/self-plagiarism.d.ts +47 -0
- package/dist/governor/self-plagiarism.js +200 -0
- package/dist/governor/write-rate-limit.d.ts +52 -0
- package/dist/governor/write-rate-limit.js +132 -0
- package/dist/governor-ledger.d.ts +25 -0
- package/dist/governor-ledger.js +91 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +120 -0
- package/dist/issue-plan-decomposition.d.ts +18 -0
- package/dist/issue-plan-decomposition.js +56 -0
- package/dist/issue-rag-query.d.ts +12 -0
- package/dist/issue-rag-query.js +35 -0
- package/dist/local-scorer.d.ts +34 -0
- package/dist/local-scorer.js +36 -0
- package/dist/metadata-best-min-score.d.ts +9 -0
- package/dist/metadata-best-min-score.js +9 -0
- package/dist/metadata-best-pick.d.ts +9 -0
- package/dist/metadata-best-pick.js +9 -0
- package/dist/metadata-min-score.d.ts +9 -0
- package/dist/metadata-min-score.js +13 -0
- package/dist/metadata-top-min-score.d.ts +9 -0
- package/dist/metadata-top-min-score.js +13 -0
- package/dist/metadata-top-pick.d.ts +9 -0
- package/dist/metadata-top-pick.js +13 -0
- package/dist/miner/acceptance-criteria.d.ts +43 -0
- package/dist/miner/acceptance-criteria.js +77 -0
- package/dist/miner/agent-sdk-driver.d.ts +39 -0
- package/dist/miner/agent-sdk-driver.js +162 -0
- package/dist/miner/attempt-log.d.ts +29 -0
- package/dist/miner/attempt-log.js +100 -0
- package/dist/miner/attempt-metering.d.ts +39 -0
- package/dist/miner/attempt-metering.js +67 -0
- package/dist/miner/cli-subprocess-driver.d.ts +68 -0
- package/dist/miner/cli-subprocess-driver.js +272 -0
- package/dist/miner/coding-agent-driver.d.ts +33 -0
- package/dist/miner/coding-agent-driver.js +36 -0
- package/dist/miner/coding-agent-invoke.d.ts +11 -0
- package/dist/miner/coding-agent-invoke.js +80 -0
- package/dist/miner/coding-agent-mode.d.ts +25 -0
- package/dist/miner/coding-agent-mode.js +43 -0
- package/dist/miner/driver-factory.d.ts +82 -0
- package/dist/miner/driver-factory.js +169 -0
- package/dist/miner/harness-submission-trigger.d.ts +37 -0
- package/dist/miner/harness-submission-trigger.js +46 -0
- package/dist/miner/iterate-loop.d.ts +97 -0
- package/dist/miner/iterate-loop.js +278 -0
- package/dist/miner/iterate-policy.d.ts +98 -0
- package/dist/miner/iterate-policy.js +98 -0
- package/dist/miner/lint-guard.d.ts +50 -0
- package/dist/miner/lint-guard.js +68 -0
- package/dist/miner/local-write-tools.d.ts +81 -0
- package/dist/miner/local-write-tools.js +113 -0
- package/dist/miner/loop-reentry-policy.d.ts +36 -0
- package/dist/miner/loop-reentry-policy.js +53 -0
- package/dist/miner/repo-map.d.ts +51 -0
- package/dist/miner/repo-map.js +268 -0
- package/dist/miner/self-review-adapter.d.ts +106 -0
- package/dist/miner/self-review-adapter.js +83 -0
- package/dist/miner/submission-gate.d.ts +40 -0
- package/dist/miner/submission-gate.js +89 -0
- package/dist/miner/worktree-allocator.d.ts +59 -0
- package/dist/miner/worktree-allocator.js +60 -0
- package/dist/miner/worktree-pool.d.ts +55 -0
- package/dist/miner/worktree-pool.js +65 -0
- package/dist/miner-goal-lane-fit.d.ts +18 -0
- package/dist/miner-goal-lane-fit.js +76 -0
- package/dist/miner-goal-spec.d.ts +139 -0
- package/dist/miner-goal-spec.js +281 -0
- package/dist/miner-prediction-metrics.d.ts +16 -0
- package/dist/miner-prediction-metrics.js +61 -0
- package/dist/miner-telemetry.d.ts +32 -0
- package/dist/miner-telemetry.js +87 -0
- package/dist/objective-anchor.d.ts +125 -0
- package/dist/objective-anchor.js +401 -0
- package/dist/opportunity-competition.d.ts +6 -0
- package/dist/opportunity-competition.js +29 -0
- package/dist/opportunity-freshness.d.ts +18 -0
- package/dist/opportunity-freshness.js +55 -0
- package/dist/opportunity-metadata.d.ts +53 -0
- package/dist/opportunity-metadata.js +204 -0
- package/dist/opportunity-ranker.d.ts +46 -0
- package/dist/opportunity-ranker.js +74 -0
- package/dist/pairwise-calibration.d.ts +49 -0
- package/dist/pairwise-calibration.js +106 -0
- package/dist/phase7-calibration-loop.d.ts +136 -0
- package/dist/phase7-calibration-loop.js +437 -0
- package/dist/plan-blocked.d.ts +6 -0
- package/dist/plan-blocked.js +24 -0
- package/dist/plan-completed.d.ts +5 -0
- package/dist/plan-completed.js +6 -0
- package/dist/plan-completion.d.ts +6 -0
- package/dist/plan-completion.js +7 -0
- package/dist/plan-empty.d.ts +5 -0
- package/dist/plan-empty.js +6 -0
- package/dist/plan-export.d.ts +27 -0
- package/dist/plan-export.js +86 -0
- package/dist/plan-failure.d.ts +5 -0
- package/dist/plan-failure.js +6 -0
- package/dist/plan-overall-status.d.ts +6 -0
- package/dist/plan-overall-status.js +25 -0
- package/dist/plan-pending.d.ts +5 -0
- package/dist/plan-pending.js +6 -0
- package/dist/plan-progress-complete.d.ts +7 -0
- package/dist/plan-progress-complete.js +10 -0
- package/dist/plan-ready.d.ts +6 -0
- package/dist/plan-ready.js +12 -0
- package/dist/plan-running.d.ts +5 -0
- package/dist/plan-running.js +6 -0
- package/dist/plan-skipped.d.ts +5 -0
- package/dist/plan-skipped.js +6 -0
- package/dist/plan-step-count.d.ts +5 -0
- package/dist/plan-step-count.js +6 -0
- package/dist/plan-step-stats.d.ts +5 -0
- package/dist/plan-step-stats.js +6 -0
- package/dist/plan-templates.d.ts +25 -0
- package/dist/plan-templates.js +107 -0
- package/dist/plan-terminated.d.ts +6 -0
- package/dist/plan-terminated.js +9 -0
- package/dist/portfolio/non-convergence.d.ts +37 -0
- package/dist/portfolio/non-convergence.js +57 -0
- package/dist/portfolio/queue.d.ts +30 -0
- package/dist/portfolio/queue.js +150 -0
- package/dist/predicted-gate.d.ts +103 -0
- package/dist/predicted-gate.js +218 -0
- package/dist/prompt-packet.d.ts +11 -0
- package/dist/prompt-packet.js +34 -0
- package/dist/ranked-opportunity-best-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-best-min-score.js +9 -0
- package/dist/ranked-opportunity-best-pick.d.ts +8 -0
- package/dist/ranked-opportunity-best-pick.js +9 -0
- package/dist/ranked-opportunity-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-min-score.js +13 -0
- package/dist/ranked-opportunity-top-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-top-min-score.js +13 -0
- package/dist/review/advisory-ai-routing-config.d.ts +8 -0
- package/dist/review/advisory-ai-routing-config.js +40 -0
- package/dist/review/check-names.d.ts +1 -0
- package/dist/review/check-names.js +1 -0
- package/dist/review/cla-check.d.ts +49 -0
- package/dist/review/cla-check.js +71 -0
- package/dist/review/diff-file-priority.d.ts +1 -0
- package/dist/review/diff-file-priority.js +12 -0
- package/dist/review/enrichment-analyzer-names.d.ts +3 -0
- package/dist/review/enrichment-analyzer-names.js +63 -0
- package/dist/review/guardrail-config.d.ts +10 -0
- package/dist/review/guardrail-config.js +62 -0
- package/dist/review/linked-issue-hard-rules-config.d.ts +4 -0
- package/dist/review/linked-issue-hard-rules-config.js +79 -0
- package/dist/review/linked-issue-label-propagation.d.ts +9 -0
- package/dist/review/linked-issue-label-propagation.js +114 -0
- package/dist/review/pre-merge-checks.d.ts +28 -0
- package/dist/review/pre-merge-checks.js +66 -0
- package/dist/review/review-thread-findings.d.ts +1 -0
- package/dist/review/review-thread-findings.js +1 -0
- package/dist/review/safe-url.d.ts +5 -0
- package/dist/review/safe-url.js +137 -0
- package/dist/review/screenshot-table-gate.d.ts +87 -0
- package/dist/review/screenshot-table-gate.js +340 -0
- package/dist/review/unlinked-issue-guardrail-config.d.ts +9 -0
- package/dist/review/unlinked-issue-guardrail-config.js +44 -0
- package/dist/reviewer-consensus-calibration.d.ts +113 -0
- package/dist/reviewer-consensus-calibration.js +493 -0
- package/dist/reward-risk.d.ts +223 -0
- package/dist/reward-risk.js +618 -0
- package/dist/scoring/label-match.d.ts +4 -0
- package/dist/scoring/label-match.js +145 -0
- package/dist/scoring/model.d.ts +31 -0
- package/dist/scoring/model.js +157 -0
- package/dist/scoring/pending-pr-scenarios.d.ts +44 -0
- package/dist/scoring/pending-pr-scenarios.js +145 -0
- package/dist/scoring/preview.d.ts +246 -0
- package/dist/scoring/preview.js +1108 -0
- package/dist/scoring/types.d.ts +131 -0
- package/dist/scoring/types.js +7 -0
- package/dist/settings/auto-close-exempt.d.ts +10 -0
- package/dist/settings/auto-close-exempt.js +58 -0
- package/dist/settings/autonomy.d.ts +33 -0
- package/dist/settings/autonomy.js +80 -0
- package/dist/settings/command-authorization.d.ts +49 -0
- package/dist/settings/command-authorization.js +222 -0
- package/dist/settings/contributor-blacklist.d.ts +17 -0
- package/dist/settings/contributor-blacklist.js +86 -0
- package/dist/settings/moderation-rules.d.ts +51 -0
- package/dist/settings/moderation-rules.js +104 -0
- package/dist/settings/pr-type-label.d.ts +70 -0
- package/dist/settings/pr-type-label.js +149 -0
- package/dist/signals/change-guardrail.d.ts +49 -0
- package/dist/signals/change-guardrail.js +164 -0
- package/dist/signals/check-summary.d.ts +9 -0
- package/dist/signals/check-summary.js +12 -0
- package/dist/signals/contributor-calibration.d.ts +23 -0
- package/dist/signals/contributor-calibration.js +47 -0
- package/dist/signals/duplicate-winner.d.ts +9 -0
- package/dist/signals/duplicate-winner.js +9 -0
- package/dist/signals/path-matchers.d.ts +36 -0
- package/dist/signals/path-matchers.js +371 -0
- package/dist/signals/predicted-gate-engine.d.ts +62 -0
- package/dist/signals/predicted-gate-engine.js +895 -0
- package/dist/signals/preflight-limits.d.ts +14 -0
- package/dist/signals/preflight-limits.js +14 -0
- package/dist/signals/slop.d.ts +56 -0
- package/dist/signals/slop.js +324 -0
- package/dist/signals/test-evidence.d.ts +25 -0
- package/dist/signals/test-evidence.js +108 -0
- package/dist/subprocess-env.d.ts +23 -0
- package/dist/subprocess-env.js +74 -0
- package/dist/track-record-summary.d.ts +92 -0
- package/dist/track-record-summary.js +301 -0
- package/dist/types/manifest-deps-types.d.ts +514 -0
- package/dist/types/manifest-deps-types.js +4 -0
- package/dist/types/predicted-gate-types.d.ts +338 -0
- package/dist/types/predicted-gate-types.js +3 -0
- package/dist/types/reward-risk-types.d.ts +319 -0
- package/dist/types/reward-risk-types.js +11 -0
- package/dist/utils/json.d.ts +1 -0
- package/dist/utils/json.js +3 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/package.json +80 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type LocalRateLimitConfig = {
|
|
2
|
+
/** Maximum number of events permitted within one window. */
|
|
3
|
+
limit: number;
|
|
4
|
+
/** Rolling window length in milliseconds. */
|
|
5
|
+
windowMs: number;
|
|
6
|
+
};
|
|
7
|
+
export type LocalRateBucket = {
|
|
8
|
+
/** Events already counted in the current window. */
|
|
9
|
+
count: number;
|
|
10
|
+
/** Start of the current window as a millisecond epoch. */
|
|
11
|
+
windowStartMs: number;
|
|
12
|
+
};
|
|
13
|
+
export type LocalRateLimitDecision = {
|
|
14
|
+
/** Whether an event at `nowMs` is permitted under the bucket + config. */
|
|
15
|
+
allowed: boolean;
|
|
16
|
+
/** The configured limit, echoed for callers that render the decision. */
|
|
17
|
+
limit: number;
|
|
18
|
+
/** Events still permitted in the effective window AFTER this one (0 when blocked). */
|
|
19
|
+
remaining: number;
|
|
20
|
+
/** When the effective window resets, as a millisecond epoch. */
|
|
21
|
+
resetAtMs: number;
|
|
22
|
+
/** Milliseconds to wait before the next permitted attempt (0 when allowed). */
|
|
23
|
+
retryAfterMs: number;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Decide whether an event at `nowMs` is allowed for a rolling-window bucket. Pure: it reads the bucket and
|
|
27
|
+
* clock and returns a decision without mutating anything. A window that has fully elapsed is treated as reset,
|
|
28
|
+
* so a stale bucket at its limit is permitted again in the new window. Every numeric input is normalized first,
|
|
29
|
+
* so a non-finite, fractional, or negative count/limit/window can never produce a NaN or negative decision.
|
|
30
|
+
*/
|
|
31
|
+
export declare function evaluateLocalRateLimit(bucket: LocalRateBucket, config: LocalRateLimitConfig, nowMs: number): LocalRateLimitDecision;
|
|
32
|
+
/**
|
|
33
|
+
* Compute a jittered exponential backoff in milliseconds for a retry `attempt` (0-based). The exponential base
|
|
34
|
+
* is `baseMs * 2 ** attempt` (attempt clamped to a non-negative, bounded range), scaled by a multiplicative
|
|
35
|
+
* jitter factor drawn from `randomFn` (expected to return a value in [0, 1), like `Math.random`, but injected
|
|
36
|
+
* so tests stay deterministic): the factor lands in [0.5, 1.5), and the final delay is that product rounded to
|
|
37
|
+
* the nearest integer (so at the top of the band a delay may round up to the `1.5 * base` value). The result is
|
|
38
|
+
* always a non-negative integer.
|
|
39
|
+
*/
|
|
40
|
+
export declare function jitteredBackoffMs(baseMs: number, attempt: number, randomFn: () => number): number;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Governor local rate-limit + jittered backoff (pure).
|
|
2
|
+
// Deterministic, side-effect-free bucket math for the local Governor. Given a rolling-window bucket and a
|
|
3
|
+
// clock reading it decides whether an event is allowed and, when blocked, how long to wait; and it computes a
|
|
4
|
+
// jittered exponential backoff from an INJECTED random source (never Math.random) so it stays fully unit-
|
|
5
|
+
// testable. This module computes numbers only — it does NOT store state, schedule, or gate any write action;
|
|
6
|
+
// that enforcement wiring is a separate, maintainer-owned concern. The vocabulary mirrors the server-side
|
|
7
|
+
// `RateLimitConfig`/`RateLimitDecision` in src/auth/rate-limit.ts (that one is a Cloudflare Durable Object and
|
|
8
|
+
// is not reusable in the fully-local miner), but this variant is millisecond-based and state-free.
|
|
9
|
+
// Cap the backoff exponent so `2 ** attempt` cannot overflow into Infinity for a pathological attempt count;
|
|
10
|
+
// beyond this the delay is already saturated at its ceiling anyway.
|
|
11
|
+
const MAX_BACKOFF_EXPONENT = 30;
|
|
12
|
+
// Normalize any numeric input to a non-negative integer (a non-finite or negative value becomes 0), so counts,
|
|
13
|
+
// limits, and window lengths can never make a decision NaN, fractional, or negative.
|
|
14
|
+
function finiteNonNegativeInt(value) {
|
|
15
|
+
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Decide whether an event at `nowMs` is allowed for a rolling-window bucket. Pure: it reads the bucket and
|
|
19
|
+
* clock and returns a decision without mutating anything. A window that has fully elapsed is treated as reset,
|
|
20
|
+
* so a stale bucket at its limit is permitted again in the new window. Every numeric input is normalized first,
|
|
21
|
+
* so a non-finite, fractional, or negative count/limit/window can never produce a NaN or negative decision.
|
|
22
|
+
*/
|
|
23
|
+
export function evaluateLocalRateLimit(bucket, config, nowMs) {
|
|
24
|
+
const limit = finiteNonNegativeInt(config.limit);
|
|
25
|
+
const windowMs = finiteNonNegativeInt(config.windowMs);
|
|
26
|
+
const count = finiteNonNegativeInt(bucket.count);
|
|
27
|
+
const windowStartMs = Number.isFinite(bucket.windowStartMs) ? bucket.windowStartMs : 0;
|
|
28
|
+
const now = Number.isFinite(nowMs) ? nowMs : 0;
|
|
29
|
+
const windowElapsed = now - windowStartMs >= windowMs;
|
|
30
|
+
const effectiveCount = windowElapsed ? 0 : count;
|
|
31
|
+
const effectiveWindowStart = windowElapsed ? now : windowStartMs;
|
|
32
|
+
const resetAtMs = effectiveWindowStart + windowMs;
|
|
33
|
+
const allowed = effectiveCount < limit;
|
|
34
|
+
const remaining = allowed ? limit - effectiveCount - 1 : 0;
|
|
35
|
+
const retryAfterMs = allowed ? 0 : Math.max(0, resetAtMs - now);
|
|
36
|
+
return { allowed, limit, remaining, resetAtMs, retryAfterMs };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Compute a jittered exponential backoff in milliseconds for a retry `attempt` (0-based). The exponential base
|
|
40
|
+
* is `baseMs * 2 ** attempt` (attempt clamped to a non-negative, bounded range), scaled by a multiplicative
|
|
41
|
+
* jitter factor drawn from `randomFn` (expected to return a value in [0, 1), like `Math.random`, but injected
|
|
42
|
+
* so tests stay deterministic): the factor lands in [0.5, 1.5), and the final delay is that product rounded to
|
|
43
|
+
* the nearest integer (so at the top of the band a delay may round up to the `1.5 * base` value). The result is
|
|
44
|
+
* always a non-negative integer.
|
|
45
|
+
*/
|
|
46
|
+
export function jitteredBackoffMs(baseMs, attempt, randomFn) {
|
|
47
|
+
// Normalize non-finite inputs so the result can never be NaN or Infinity — it is always a non-negative integer,
|
|
48
|
+
// matching this function's documented contract. A non-finite base is treated as 0; a NaN attempt means no
|
|
49
|
+
// backoff growth, while a huge or Infinity attempt saturates at the capped exponent.
|
|
50
|
+
const safeBase = Number.isFinite(baseMs) ? Math.max(0, baseMs) : 0;
|
|
51
|
+
const exponent = Number.isNaN(attempt) ? 0 : Math.min(MAX_BACKOFF_EXPONENT, Math.max(0, Math.floor(attempt)));
|
|
52
|
+
const exponential = safeBase * 2 ** exponent;
|
|
53
|
+
// Clamp the random draw into [0, 1) so an out-of-contract source cannot push the factor outside [0.5, 1.5).
|
|
54
|
+
// A non-finite draw (e.g. NaN) is treated as 0 so the delay never becomes NaN.
|
|
55
|
+
const rawDraw = randomFn();
|
|
56
|
+
const draw = Number.isFinite(rawDraw) ? Math.min(0.999999, Math.max(0, rawDraw)) : 0;
|
|
57
|
+
const jitterFactor = 0.5 + draw;
|
|
58
|
+
// Round to an integer AND guard finiteness on the return itself: a fractional base yields a rounded integer,
|
|
59
|
+
// and an extreme (but finite) base that overflows the multiplication to Infinity falls back to a finite max —
|
|
60
|
+
// so the result is always a non-negative integer for any input, per the documented contract.
|
|
61
|
+
const rawDelay = exponential * jitterFactor;
|
|
62
|
+
return Number.isFinite(rawDelay) ? Math.max(0, Math.round(rawDelay)) : Number.MAX_SAFE_INTEGER;
|
|
63
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { GovernorLedgerEvent } from "../governor-ledger.js";
|
|
2
|
+
export type SelfReputationThresholds = {
|
|
3
|
+
/** Terminal outcomes required on a repo before throttling engages; below this it fails OPEN (full cadence). */
|
|
4
|
+
minSampleSize: number;
|
|
5
|
+
/** Unfavorable ratio (unfavorable / decided) at which cadence starts degrading below full. */
|
|
6
|
+
throttleAtRatio: number;
|
|
7
|
+
/** Unfavorable ratio at (or above) which cadence is pinned to its floor. */
|
|
8
|
+
floorAtRatio: number;
|
|
9
|
+
/** Cadence multiplier at/above `floorAtRatio` — the slowest permitted fraction of normal cadence (never 0). */
|
|
10
|
+
minCadenceFactor: number;
|
|
11
|
+
};
|
|
12
|
+
/** Conservative built-in defaults; a `.gittensory-miner.yml` override is merged over these. */
|
|
13
|
+
export declare const DEFAULT_SELF_REPUTATION_THRESHOLDS: SelfReputationThresholds;
|
|
14
|
+
/** The miner's own terminal outcomes on one repo over its recent-history window. */
|
|
15
|
+
export type RepoOutcomeHistory = {
|
|
16
|
+
/** Submissions with a terminal outcome (merged + closed + rejected). */
|
|
17
|
+
decided: number;
|
|
18
|
+
/** Terminal outcomes that went against the miner (human-closed or gate-rejected). */
|
|
19
|
+
unfavorable: number;
|
|
20
|
+
};
|
|
21
|
+
export type SelfReputationThrottleReason = "insufficient_history" | "clean" | "throttled" | "floored";
|
|
22
|
+
export type SelfReputationThrottleDecision = {
|
|
23
|
+
/** Multiplier on normal submission cadence, in [minCadenceFactor, 1]. 1 = unthrottled. */
|
|
24
|
+
cadenceFactor: number;
|
|
25
|
+
throttled: boolean;
|
|
26
|
+
/** The unfavorable ratio that drove the decision; null when below the sample floor (fail-open). */
|
|
27
|
+
unfavorableRatio: number | null;
|
|
28
|
+
reason: SelfReputationThrottleReason;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Merge a partial (e.g. `.gittensory-miner.yml`-sourced) threshold config over the conservative defaults,
|
|
32
|
+
* normalizing every field so a malformed value can never produce a NaN/negative/out-of-range decision. The
|
|
33
|
+
* throttle band is kept well-formed: `floorAtRatio` is pulled to at least `throttleAtRatio` so the interpolation
|
|
34
|
+
* span is never negative.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveSelfReputationThresholds(overrides?: Partial<SelfReputationThresholds>): SelfReputationThresholds;
|
|
37
|
+
/**
|
|
38
|
+
* Decide the cadence throttle for one repo from the miner's own recent outcome history. Pure: reads the history
|
|
39
|
+
* and thresholds and returns a decision without mutating anything. Fails OPEN on genuinely insufficient history
|
|
40
|
+
* (fewer than `minSampleSize` decided outcomes) — a brand-new miner or a new repo is never falsely throttled.
|
|
41
|
+
* Between `throttleAtRatio` and `floorAtRatio` the cadence factor interpolates linearly from 1 down to
|
|
42
|
+
* `minCadenceFactor`, so an improving ratio measurably restores cadence and a worsening one measurably cuts it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function selfReputationThrottle(history: RepoOutcomeHistory, thresholds?: SelfReputationThresholds): SelfReputationThrottleDecision;
|
|
45
|
+
/**
|
|
46
|
+
* Shape a throttle decision as a governor-ledger event so the chokepoint can record WHY a submission cadence was
|
|
47
|
+
* scaled, with the outcome ratio that triggered it. An unthrottled decision is an `allowed` event; a throttled
|
|
48
|
+
* one is a `throttled` event.
|
|
49
|
+
*/
|
|
50
|
+
export declare function selfReputationThrottleLedgerEvent(repoFullName: string, actionClass: string, decision: SelfReputationThrottleDecision): GovernorLedgerEvent;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Conservative built-in defaults; a `.gittensory-miner.yml` override is merged over these. */
|
|
2
|
+
export const DEFAULT_SELF_REPUTATION_THRESHOLDS = Object.freeze({
|
|
3
|
+
minSampleSize: 5,
|
|
4
|
+
throttleAtRatio: 0.5,
|
|
5
|
+
floorAtRatio: 0.9,
|
|
6
|
+
minCadenceFactor: 0.1,
|
|
7
|
+
});
|
|
8
|
+
function finiteNonNegativeInt(value) {
|
|
9
|
+
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
10
|
+
}
|
|
11
|
+
function clampFraction(value, fallback) {
|
|
12
|
+
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
|
|
13
|
+
}
|
|
14
|
+
function round3(value) {
|
|
15
|
+
return Number(value.toFixed(3));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Merge a partial (e.g. `.gittensory-miner.yml`-sourced) threshold config over the conservative defaults,
|
|
19
|
+
* normalizing every field so a malformed value can never produce a NaN/negative/out-of-range decision. The
|
|
20
|
+
* throttle band is kept well-formed: `floorAtRatio` is pulled to at least `throttleAtRatio` so the interpolation
|
|
21
|
+
* span is never negative.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveSelfReputationThresholds(overrides = {}) {
|
|
24
|
+
const d = DEFAULT_SELF_REPUTATION_THRESHOLDS;
|
|
25
|
+
const minSampleSize = Math.max(1, finiteNonNegativeInt(overrides.minSampleSize ?? d.minSampleSize));
|
|
26
|
+
const throttleAtRatio = clampFraction(overrides.throttleAtRatio ?? d.throttleAtRatio, d.throttleAtRatio);
|
|
27
|
+
const floorAtRatio = Math.max(throttleAtRatio, clampFraction(overrides.floorAtRatio ?? d.floorAtRatio, d.floorAtRatio));
|
|
28
|
+
const minCadenceFactor = clampFraction(overrides.minCadenceFactor ?? d.minCadenceFactor, d.minCadenceFactor);
|
|
29
|
+
return { minSampleSize, throttleAtRatio, floorAtRatio, minCadenceFactor };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Decide the cadence throttle for one repo from the miner's own recent outcome history. Pure: reads the history
|
|
33
|
+
* and thresholds and returns a decision without mutating anything. Fails OPEN on genuinely insufficient history
|
|
34
|
+
* (fewer than `minSampleSize` decided outcomes) — a brand-new miner or a new repo is never falsely throttled.
|
|
35
|
+
* Between `throttleAtRatio` and `floorAtRatio` the cadence factor interpolates linearly from 1 down to
|
|
36
|
+
* `minCadenceFactor`, so an improving ratio measurably restores cadence and a worsening one measurably cuts it.
|
|
37
|
+
*/
|
|
38
|
+
export function selfReputationThrottle(history, thresholds = DEFAULT_SELF_REPUTATION_THRESHOLDS) {
|
|
39
|
+
const decided = finiteNonNegativeInt(history.decided);
|
|
40
|
+
const unfavorable = Math.min(decided, finiteNonNegativeInt(history.unfavorable));
|
|
41
|
+
if (decided < thresholds.minSampleSize) {
|
|
42
|
+
return {
|
|
43
|
+
cadenceFactor: 1,
|
|
44
|
+
throttled: false,
|
|
45
|
+
unfavorableRatio: null,
|
|
46
|
+
reason: "insufficient_history",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const ratio = unfavorable / decided;
|
|
50
|
+
if (ratio < thresholds.throttleAtRatio) {
|
|
51
|
+
return {
|
|
52
|
+
cadenceFactor: 1,
|
|
53
|
+
throttled: false,
|
|
54
|
+
unfavorableRatio: round3(ratio),
|
|
55
|
+
reason: "clean",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (ratio >= thresholds.floorAtRatio) {
|
|
59
|
+
return {
|
|
60
|
+
cadenceFactor: thresholds.minCadenceFactor,
|
|
61
|
+
throttled: true,
|
|
62
|
+
unfavorableRatio: round3(ratio),
|
|
63
|
+
reason: "floored",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// throttleAtRatio <= ratio < floorAtRatio ⇒ floorAtRatio > throttleAtRatio, so the span is strictly positive.
|
|
67
|
+
const t = (ratio - thresholds.throttleAtRatio) /
|
|
68
|
+
(thresholds.floorAtRatio - thresholds.throttleAtRatio);
|
|
69
|
+
const cadenceFactor = round3(1 - t * (1 - thresholds.minCadenceFactor));
|
|
70
|
+
return {
|
|
71
|
+
cadenceFactor,
|
|
72
|
+
throttled: true,
|
|
73
|
+
unfavorableRatio: round3(ratio),
|
|
74
|
+
reason: "throttled",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Shape a throttle decision as a governor-ledger event so the chokepoint can record WHY a submission cadence was
|
|
79
|
+
* scaled, with the outcome ratio that triggered it. An unthrottled decision is an `allowed` event; a throttled
|
|
80
|
+
* one is a `throttled` event.
|
|
81
|
+
*/
|
|
82
|
+
export function selfReputationThrottleLedgerEvent(repoFullName, actionClass, decision) {
|
|
83
|
+
return {
|
|
84
|
+
eventType: decision.throttled ? "throttled" : "allowed",
|
|
85
|
+
repoFullName,
|
|
86
|
+
actionClass,
|
|
87
|
+
decision: decision.throttled ? "throttle" : "allow",
|
|
88
|
+
reason: decision.reason,
|
|
89
|
+
payload: {
|
|
90
|
+
cadenceFactor: decision.cadenceFactor,
|
|
91
|
+
unfavorableRatio: decision.unfavorableRatio,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GovernorLedgerEvent, GovernorLedgerEventType } from "../governor-ledger.js";
|
|
2
|
+
import { classifyPortfolioConvergence, type PortfolioConvergenceInput, type PortfolioConvergenceThresholds } from "../portfolio/non-convergence.js";
|
|
3
|
+
import { evaluateGovernorCaps, type GovernorCapLimits, type GovernorCapReport, type GovernorCapUsage } from "./budget-cap.js";
|
|
4
|
+
/** Issue vocabulary alias for the pure budget/turn/termination cap calculator. */
|
|
5
|
+
export declare const evaluateBudgetCaps: typeof evaluateGovernorCaps;
|
|
6
|
+
export type NonConvergenceSignal = {
|
|
7
|
+
tripped: boolean;
|
|
8
|
+
status: ReturnType<typeof classifyPortfolioConvergence>["status"];
|
|
9
|
+
reasons: string[];
|
|
10
|
+
};
|
|
11
|
+
/** Issue vocabulary wrapper over {@link classifyPortfolioConvergence}. */
|
|
12
|
+
export declare function detectNonConvergence(input: PortfolioConvergenceInput, thresholds?: PortfolioConvergenceThresholds): NonConvergenceSignal;
|
|
13
|
+
export type RunLoopHaltReason = "cleared" | "prior_halt" | "non_convergence" | "budget_exceeded" | "turn_cap_exceeded" | "termination_cap_exceeded";
|
|
14
|
+
export type RunLoopHaltVerdict = {
|
|
15
|
+
shouldHalt: boolean;
|
|
16
|
+
canClaimNext: boolean;
|
|
17
|
+
reason: RunLoopHaltReason;
|
|
18
|
+
convergence: NonConvergenceSignal;
|
|
19
|
+
caps: GovernorCapReport;
|
|
20
|
+
ledgerEventType: GovernorLedgerEventType;
|
|
21
|
+
ledgerReason: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Consult non-convergence and budget caps at a run-loop iteration boundary. A prior halt sticks until
|
|
25
|
+
* the caller clears it; otherwise either signal tripping halts the run and blocks further queue claims.
|
|
26
|
+
*/
|
|
27
|
+
export declare function evaluateRunLoopHalt(input: {
|
|
28
|
+
runHalted: boolean;
|
|
29
|
+
usage: GovernorCapUsage;
|
|
30
|
+
limits: GovernorCapLimits;
|
|
31
|
+
convergence: PortfolioConvergenceInput;
|
|
32
|
+
convergenceThresholds?: PortfolioConvergenceThresholds;
|
|
33
|
+
}): RunLoopHaltVerdict;
|
|
34
|
+
/** Reset latch after an operator clears a halted run. */
|
|
35
|
+
export declare function clearRunLoopHalt(): {
|
|
36
|
+
runHalted: false;
|
|
37
|
+
};
|
|
38
|
+
/** Governor-ledger row for a run-loop halt decision (#2347 deliverable). */
|
|
39
|
+
export declare function buildRunLoopHaltGovernorLedgerEvent(repoFullName: string | null | undefined, inFlightIdentifier: string | null | undefined, verdict: RunLoopHaltVerdict): GovernorLedgerEvent;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Governor run-loop halt enforcement (#2347): composes the pure non-convergence detector and the
|
|
2
|
+
// budget/turn/termination cap calculator at every iteration boundary before the portfolio queue claims
|
|
3
|
+
// the next item. Either signal tripping halts the current run; the caller releases in-flight work and
|
|
4
|
+
// blocks further claims until a human clears the halt.
|
|
5
|
+
import { classifyPortfolioConvergence, DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS, } from "../portfolio/non-convergence.js";
|
|
6
|
+
import { evaluateGovernorCaps, } from "./budget-cap.js";
|
|
7
|
+
/** Issue vocabulary alias for the pure budget/turn/termination cap calculator. */
|
|
8
|
+
export const evaluateBudgetCaps = evaluateGovernorCaps;
|
|
9
|
+
/** Issue vocabulary wrapper over {@link classifyPortfolioConvergence}. */
|
|
10
|
+
export function detectNonConvergence(input, thresholds = DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS) {
|
|
11
|
+
const verdict = classifyPortfolioConvergence(input, thresholds);
|
|
12
|
+
return {
|
|
13
|
+
tripped: verdict.status === "non_convergent",
|
|
14
|
+
status: verdict.status,
|
|
15
|
+
reasons: verdict.reasons,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function haltVerdict(reason, convergence, caps, ledgerEventType, ledgerReason) {
|
|
19
|
+
return {
|
|
20
|
+
shouldHalt: true,
|
|
21
|
+
canClaimNext: false,
|
|
22
|
+
reason,
|
|
23
|
+
convergence,
|
|
24
|
+
caps,
|
|
25
|
+
ledgerEventType,
|
|
26
|
+
ledgerReason,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Consult non-convergence and budget caps at a run-loop iteration boundary. A prior halt sticks until
|
|
31
|
+
* the caller clears it; otherwise either signal tripping halts the run and blocks further queue claims.
|
|
32
|
+
*/
|
|
33
|
+
export function evaluateRunLoopHalt(input) {
|
|
34
|
+
const convergence = detectNonConvergence(input.convergence, input.convergenceThresholds);
|
|
35
|
+
const caps = evaluateGovernorCaps(input.usage, input.limits);
|
|
36
|
+
if (input.runHalted) {
|
|
37
|
+
return haltVerdict("prior_halt", convergence, caps, "denied", "run_already_halted");
|
|
38
|
+
}
|
|
39
|
+
if (caps.verdict === "kill_switch") {
|
|
40
|
+
return haltVerdict("termination_cap_exceeded", convergence, caps, "kill_switch", "termination_cap_exceeded");
|
|
41
|
+
}
|
|
42
|
+
if (caps.budget.exceeded) {
|
|
43
|
+
return haltVerdict("budget_exceeded", convergence, caps, "denied", "budget_cap_exceeded");
|
|
44
|
+
}
|
|
45
|
+
if (caps.turns.exceeded) {
|
|
46
|
+
return haltVerdict("turn_cap_exceeded", convergence, caps, "denied", "turn_cap_exceeded");
|
|
47
|
+
}
|
|
48
|
+
if (convergence.tripped) {
|
|
49
|
+
return haltVerdict("non_convergence", convergence, caps, "denied", "non_convergence_detected");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
shouldHalt: false,
|
|
53
|
+
canClaimNext: true,
|
|
54
|
+
reason: "cleared",
|
|
55
|
+
convergence,
|
|
56
|
+
caps,
|
|
57
|
+
ledgerEventType: "allowed",
|
|
58
|
+
ledgerReason: "under_limit",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Reset latch after an operator clears a halted run. */
|
|
62
|
+
export function clearRunLoopHalt() {
|
|
63
|
+
return { runHalted: false };
|
|
64
|
+
}
|
|
65
|
+
/** Governor-ledger row for a run-loop halt decision (#2347 deliverable). */
|
|
66
|
+
export function buildRunLoopHaltGovernorLedgerEvent(repoFullName, inFlightIdentifier, verdict) {
|
|
67
|
+
return {
|
|
68
|
+
eventType: verdict.ledgerEventType,
|
|
69
|
+
repoFullName,
|
|
70
|
+
actionClass: "run_loop",
|
|
71
|
+
decision: verdict.shouldHalt ? "halt" : "continue",
|
|
72
|
+
reason: verdict.ledgerReason,
|
|
73
|
+
payload: {
|
|
74
|
+
haltReason: verdict.reason,
|
|
75
|
+
convergenceStatus: verdict.convergence.status,
|
|
76
|
+
convergenceReasons: verdict.convergence.reasons,
|
|
77
|
+
budgetExceeded: verdict.caps.budget.exceeded,
|
|
78
|
+
turnsExceeded: verdict.caps.turns.exceeded,
|
|
79
|
+
terminationExceeded: verdict.caps.termination.exceeded,
|
|
80
|
+
inFlightIdentifier: inFlightIdentifier ?? null,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { GovernorLedgerEventType } from "../governor-ledger.js";
|
|
2
|
+
/** Conservative default — only very similar diff fingerprints throttle (not hard-coded at call sites). */
|
|
3
|
+
export declare const DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD = 0.85;
|
|
4
|
+
export type SelfPlagiarismConfig = {
|
|
5
|
+
/** Jaccard similarity in [0, 1] at/above which two fingerprints read as near-duplicates. */
|
|
6
|
+
similarityThreshold: number;
|
|
7
|
+
};
|
|
8
|
+
export declare const DEFAULT_SELF_PLAGIARISM_CONFIG: Readonly<SelfPlagiarismConfig>;
|
|
9
|
+
/** One prior submission from the miner's own history (same actor only — never cross-miner). */
|
|
10
|
+
export type OwnSubmissionRecord = {
|
|
11
|
+
repoFullName: string;
|
|
12
|
+
/** Stable diff fingerprint for similarity comparison (caller-normalized token set or hash). */
|
|
13
|
+
fingerprint: string;
|
|
14
|
+
/** When the submission was recorded — election ordering signal (ISO-8601). */
|
|
15
|
+
submittedAt?: string | null | undefined;
|
|
16
|
+
pullRequestNumber?: number | null | undefined;
|
|
17
|
+
issueNumber?: number | null | undefined;
|
|
18
|
+
};
|
|
19
|
+
export type SelfPlagiarismCandidate = OwnSubmissionRecord;
|
|
20
|
+
export type SelfPlagiarismVerdict = {
|
|
21
|
+
allowed: boolean;
|
|
22
|
+
/** Aligns with governor-ledger vocabulary: `allowed`, `throttled`, or `denied`. */
|
|
23
|
+
eventType: GovernorLedgerEventType;
|
|
24
|
+
reason: string;
|
|
25
|
+
/** Highest-similarity prior that triggered the throttle, when present. */
|
|
26
|
+
matchedSubmission?: OwnSubmissionRecord;
|
|
27
|
+
similarity?: number;
|
|
28
|
+
};
|
|
29
|
+
/** Token-set Jaccard similarity — deterministic and dependency-free for diff fingerprint comparison. */
|
|
30
|
+
export declare function fingerprintSimilarity(left: string, right: string): number;
|
|
31
|
+
/**
|
|
32
|
+
* Compare a prospective PR fingerprint against the miner's own recent submissions. Fail closed when the
|
|
33
|
+
* candidate fingerprint or election timing is missing/ambiguous. When near-duplicates exist, only the
|
|
34
|
+
* earliest claimant wins — later submissions are throttled.
|
|
35
|
+
*/
|
|
36
|
+
export declare function selfPlagiarismCheck(candidateFingerprint: SelfPlagiarismCandidate, recentOwnSubmissions: readonly OwnSubmissionRecord[], config?: SelfPlagiarismConfig): SelfPlagiarismVerdict;
|
|
37
|
+
/** Governor-ledger row shape for an open_pr self-plagiarism decision (#2345 deliverable). */
|
|
38
|
+
export declare function buildSelfPlagiarismGovernorLedgerEvent(repoFullName: string, verdict: SelfPlagiarismVerdict): {
|
|
39
|
+
eventType: GovernorLedgerEventType;
|
|
40
|
+
repoFullName: string;
|
|
41
|
+
actionClass: string;
|
|
42
|
+
decision: string;
|
|
43
|
+
reason: string;
|
|
44
|
+
payload: Record<string, unknown>;
|
|
45
|
+
};
|
|
46
|
+
/** Normalize a miner-goal-spec selfPlagiarism block (or bare threshold number) into engine config. */
|
|
47
|
+
export declare function resolveSelfPlagiarismConfig(raw: unknown): SelfPlagiarismConfig;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Self-plagiarism throttle (#2345): pure classifier over a prospective PR's diff fingerprint vs the miner's own
|
|
2
|
+
// recent submission history. Gates nothing on its own — the Governor open_pr chokepoint (#2340) composes this
|
|
3
|
+
// verdict with rate-limit, budget caps, and non-convergence before recording to the governor ledger.
|
|
4
|
+
//
|
|
5
|
+
// ELECTION: reuses {@link isDuplicateClusterWinnerByClaim}'s claim-time / earliest-wins ordering so a
|
|
6
|
+
// near-duplicate cluster has exactly one survivor — sparse or ambiguous timing fails closed (deny), mirroring
|
|
7
|
+
// duplicate-cluster adjudication in src/signals/duplicate-winner.ts.
|
|
8
|
+
//
|
|
9
|
+
// DETECTOR ONLY — no IO, no Date.now(), no randomness. Identical inputs always yield the identical verdict.
|
|
10
|
+
/** Conservative default — only very similar diff fingerprints throttle (not hard-coded at call sites). */
|
|
11
|
+
export const DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD = 0.85;
|
|
12
|
+
export const DEFAULT_SELF_PLAGIARISM_CONFIG = Object.freeze({
|
|
13
|
+
similarityThreshold: DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD,
|
|
14
|
+
});
|
|
15
|
+
function normalizeThreshold(value) {
|
|
16
|
+
if (!Number.isFinite(value))
|
|
17
|
+
return DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD;
|
|
18
|
+
return Math.min(1, Math.max(0, value));
|
|
19
|
+
}
|
|
20
|
+
function normalizeFingerprint(value) {
|
|
21
|
+
if (typeof value !== "string")
|
|
22
|
+
return null;
|
|
23
|
+
const trimmed = value.trim().toLowerCase();
|
|
24
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
25
|
+
}
|
|
26
|
+
function tokenSet(fingerprint) {
|
|
27
|
+
return new Set(fingerprint
|
|
28
|
+
.split(/[\s:,]+/)
|
|
29
|
+
.map((token) => token.trim())
|
|
30
|
+
.filter(Boolean));
|
|
31
|
+
}
|
|
32
|
+
/** Token-set Jaccard similarity — deterministic and dependency-free for diff fingerprint comparison. */
|
|
33
|
+
export function fingerprintSimilarity(left, right) {
|
|
34
|
+
const setLeft = tokenSet(normalizeFingerprint(left) ?? "");
|
|
35
|
+
const setRight = tokenSet(normalizeFingerprint(right) ?? "");
|
|
36
|
+
if (setLeft.size === 0 && setRight.size === 0)
|
|
37
|
+
return 1;
|
|
38
|
+
if (setLeft.size === 0 || setRight.size === 0)
|
|
39
|
+
return 0;
|
|
40
|
+
let intersection = 0;
|
|
41
|
+
for (const token of setLeft) {
|
|
42
|
+
if (setRight.has(token))
|
|
43
|
+
intersection += 1;
|
|
44
|
+
}
|
|
45
|
+
const union = setLeft.size + setRight.size - intersection;
|
|
46
|
+
return intersection / union;
|
|
47
|
+
}
|
|
48
|
+
function submissionTimeMs(value) {
|
|
49
|
+
if (!value)
|
|
50
|
+
return null;
|
|
51
|
+
const parsed = Date.parse(value);
|
|
52
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
53
|
+
}
|
|
54
|
+
function submissionNumber(record) {
|
|
55
|
+
return ((typeof record.pullRequestNumber === "number" &&
|
|
56
|
+
Number.isFinite(record.pullRequestNumber)
|
|
57
|
+
? record.pullRequestNumber
|
|
58
|
+
: null) ??
|
|
59
|
+
(typeof record.issueNumber === "number" &&
|
|
60
|
+
Number.isFinite(record.issueNumber)
|
|
61
|
+
? record.issueNumber
|
|
62
|
+
: null) ??
|
|
63
|
+
0);
|
|
64
|
+
}
|
|
65
|
+
function repoTieBreaker(record) {
|
|
66
|
+
return record.repoFullName.trim().toLowerCase();
|
|
67
|
+
}
|
|
68
|
+
function submissionPrecedesSibling(candidate, sibling) {
|
|
69
|
+
const candidateTime = submissionTimeMs(candidate.submittedAt);
|
|
70
|
+
const siblingTime = submissionTimeMs(sibling.submittedAt);
|
|
71
|
+
if (siblingTime < candidateTime)
|
|
72
|
+
return false;
|
|
73
|
+
if (siblingTime > candidateTime)
|
|
74
|
+
return true;
|
|
75
|
+
const candidateNumber = submissionNumber(candidate);
|
|
76
|
+
const siblingNumber = submissionNumber(sibling);
|
|
77
|
+
if (siblingNumber < candidateNumber)
|
|
78
|
+
return false;
|
|
79
|
+
if (siblingNumber > candidateNumber)
|
|
80
|
+
return true;
|
|
81
|
+
const candidateRepo = repoTieBreaker(candidate);
|
|
82
|
+
const siblingRepo = repoTieBreaker(sibling);
|
|
83
|
+
if (siblingRepo.length === 0 || candidateRepo.length === 0)
|
|
84
|
+
return false;
|
|
85
|
+
return candidateRepo < siblingRepo;
|
|
86
|
+
}
|
|
87
|
+
function isSelfPlagiarismClusterWinner(candidate, nearDuplicates) {
|
|
88
|
+
return nearDuplicates.every((sibling) => submissionPrecedesSibling(candidate, sibling));
|
|
89
|
+
}
|
|
90
|
+
// Precondition: the caller (selfPlagiarismCheck) already confirmed `candidate` is NOT the outright
|
|
91
|
+
// winner (isSelfPlagiarismClusterWinner returned false) before calling this -- so this only needs to
|
|
92
|
+
// search nearDuplicates for a sibling that wins instead.
|
|
93
|
+
function resolveSelfPlagiarismWinner(candidate, nearDuplicates) {
|
|
94
|
+
for (const sibling of nearDuplicates) {
|
|
95
|
+
const rest = nearDuplicates.filter((other) => other !== sibling);
|
|
96
|
+
if (isSelfPlagiarismClusterWinner(sibling, [candidate, ...rest]))
|
|
97
|
+
return sibling;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function buildVerdict(allowed, eventType, reason, matchedSubmission, similarity) {
|
|
102
|
+
return {
|
|
103
|
+
allowed,
|
|
104
|
+
eventType,
|
|
105
|
+
reason,
|
|
106
|
+
...(matchedSubmission ? { matchedSubmission } : {}),
|
|
107
|
+
...(similarity !== undefined ? { similarity } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Compare a prospective PR fingerprint against the miner's own recent submissions. Fail closed when the
|
|
112
|
+
* candidate fingerprint or election timing is missing/ambiguous. When near-duplicates exist, only the
|
|
113
|
+
* earliest claimant wins — later submissions are throttled.
|
|
114
|
+
*/
|
|
115
|
+
export function selfPlagiarismCheck(candidateFingerprint, recentOwnSubmissions, config = DEFAULT_SELF_PLAGIARISM_CONFIG) {
|
|
116
|
+
const threshold = normalizeThreshold(config.similarityThreshold);
|
|
117
|
+
const candidatePrint = normalizeFingerprint(candidateFingerprint.fingerprint);
|
|
118
|
+
if (candidatePrint === null) {
|
|
119
|
+
return buildVerdict(false, "denied", "missing_candidate_fingerprint");
|
|
120
|
+
}
|
|
121
|
+
if (submissionTimeMs(candidateFingerprint.submittedAt) === null) {
|
|
122
|
+
return buildVerdict(false, "denied", "missing_candidate_submitted_at");
|
|
123
|
+
}
|
|
124
|
+
let bestMatch;
|
|
125
|
+
let bestSimilarity = 0;
|
|
126
|
+
const nearDuplicates = [];
|
|
127
|
+
for (const prior of recentOwnSubmissions) {
|
|
128
|
+
const priorPrint = normalizeFingerprint(prior.fingerprint);
|
|
129
|
+
if (priorPrint === null)
|
|
130
|
+
continue;
|
|
131
|
+
const similarity = fingerprintSimilarity(candidatePrint, priorPrint);
|
|
132
|
+
if (similarity >= threshold) {
|
|
133
|
+
nearDuplicates.push(prior);
|
|
134
|
+
if (similarity > bestSimilarity) {
|
|
135
|
+
bestSimilarity = similarity;
|
|
136
|
+
bestMatch = prior;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (nearDuplicates.length === 0) {
|
|
141
|
+
return buildVerdict(true, "allowed", "distinct_from_recent_own_submissions");
|
|
142
|
+
}
|
|
143
|
+
for (const prior of nearDuplicates) {
|
|
144
|
+
if (submissionTimeMs(prior.submittedAt) === null) {
|
|
145
|
+
return buildVerdict(false, "denied", "missing_prior_submitted_at");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (isSelfPlagiarismClusterWinner(candidateFingerprint, nearDuplicates)) {
|
|
149
|
+
return buildVerdict(true, "allowed", "earliest_near_duplicate_claimant");
|
|
150
|
+
}
|
|
151
|
+
const winner = resolveSelfPlagiarismWinner(candidateFingerprint, nearDuplicates);
|
|
152
|
+
const matched = winner && winner !== candidateFingerprint
|
|
153
|
+
? winner
|
|
154
|
+
: (bestMatch ?? nearDuplicates[0]);
|
|
155
|
+
const matchedPrint = normalizeFingerprint(matched.fingerprint);
|
|
156
|
+
return buildVerdict(false, "throttled", "near_duplicate_self_plagiarism", matched, bestSimilarity > 0
|
|
157
|
+
? bestSimilarity
|
|
158
|
+
: fingerprintSimilarity(candidatePrint, matchedPrint));
|
|
159
|
+
}
|
|
160
|
+
/** Governor-ledger row shape for an open_pr self-plagiarism decision (#2345 deliverable). */
|
|
161
|
+
export function buildSelfPlagiarismGovernorLedgerEvent(repoFullName, verdict) {
|
|
162
|
+
const matched = verdict.matchedSubmission;
|
|
163
|
+
return {
|
|
164
|
+
eventType: verdict.eventType,
|
|
165
|
+
repoFullName,
|
|
166
|
+
actionClass: "open_pr",
|
|
167
|
+
decision: verdict.allowed
|
|
168
|
+
? "allow"
|
|
169
|
+
: verdict.eventType === "throttled"
|
|
170
|
+
? "throttle"
|
|
171
|
+
: "deny",
|
|
172
|
+
reason: verdict.reason,
|
|
173
|
+
payload: matched
|
|
174
|
+
? {
|
|
175
|
+
matchedRepoFullName: matched.repoFullName,
|
|
176
|
+
matchedPullRequestNumber: matched.pullRequestNumber ?? null,
|
|
177
|
+
matchedIssueNumber: matched.issueNumber ?? null,
|
|
178
|
+
matchedSubmittedAt: matched.submittedAt ?? null,
|
|
179
|
+
similarity: verdict.similarity ?? null,
|
|
180
|
+
}
|
|
181
|
+
: {},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** Normalize a miner-goal-spec selfPlagiarism block (or bare threshold number) into engine config. */
|
|
185
|
+
export function resolveSelfPlagiarismConfig(raw) {
|
|
186
|
+
if (raw === undefined || raw === null)
|
|
187
|
+
return { ...DEFAULT_SELF_PLAGIARISM_CONFIG };
|
|
188
|
+
if (typeof raw === "number") {
|
|
189
|
+
return { similarityThreshold: normalizeThreshold(raw) };
|
|
190
|
+
}
|
|
191
|
+
if (typeof raw === "object" && !Array.isArray(raw)) {
|
|
192
|
+
const record = raw;
|
|
193
|
+
return {
|
|
194
|
+
similarityThreshold: normalizeThreshold(typeof record.similarityThreshold === "number"
|
|
195
|
+
? record.similarityThreshold
|
|
196
|
+
: DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return { ...DEFAULT_SELF_PLAGIARISM_CONFIG };
|
|
200
|
+
}
|