Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gittensory-miner.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,8 @@ issueDiscoveryPolicy: neutral
feasibilityGate:
enabled: true
suppressedReasons: []

# Self-plagiarism throttle (#2345): similarity threshold for the miner's own recent open_pr fingerprints.
# Float in [0, 1]. Default: 0.85.
selfPlagiarism:
similarityThreshold: 0.85
241 changes: 241 additions & 0 deletions packages/gittensory-engine/src/governor/self-plagiarism.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// Self-plagiarism throttle (#2345): pure classifier over a prospective PR's diff fingerprint vs the miner's own
// recent submission history. Gates nothing on its own — the Governor open_pr chokepoint (#2340) composes this
// verdict with rate-limit, budget caps, and non-convergence before recording to the governor ledger.
//
// ELECTION: reuses {@link isDuplicateClusterWinnerByClaim}'s claim-time / earliest-wins ordering so a
// near-duplicate cluster has exactly one survivor — sparse or ambiguous timing fails closed (deny), mirroring
// duplicate-cluster adjudication in src/signals/duplicate-winner.ts.
//
// DETECTOR ONLY — no IO, no Date.now(), no randomness. Identical inputs always yield the identical verdict.

import {
isDuplicateClusterWinnerByClaim,
resolveDuplicateClusterWinnerNumber,
type DuplicateClaimMember,
} from "../duplicate-winner.js";
import type { GovernorLedgerEventType } from "../governor-ledger.js";

/** Conservative default — only very similar diff fingerprints throttle (not hard-coded at call sites). */
export const DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD = 0.85;

export type SelfPlagiarismConfig = {
/** Jaccard similarity in [0, 1] at/above which two fingerprints read as near-duplicates. */
similarityThreshold: number;
};

export const DEFAULT_SELF_PLAGIARISM_CONFIG: Readonly<SelfPlagiarismConfig> = Object.freeze({
similarityThreshold: DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD,
});

/** One prior submission from the miner's own history (same actor only — never cross-miner). */
export type OwnSubmissionRecord = {
repoFullName: string;
/** Stable diff fingerprint for similarity comparison (caller-normalized token set or hash). */
fingerprint: string;
/** When the submission was recorded — election ordering signal (ISO-8601). */
submittedAt?: string | null | undefined;
pullRequestNumber?: number | null | undefined;
issueNumber?: number | null | undefined;
};

export type SelfPlagiarismCandidate = OwnSubmissionRecord;

export type SelfPlagiarismVerdict = {
allowed: boolean;
/** Aligns with governor-ledger vocabulary: `allowed`, `throttled`, or `denied`. */
eventType: GovernorLedgerEventType;
reason: string;
/** Highest-similarity prior that triggered the throttle, when present. */
matchedSubmission?: OwnSubmissionRecord;
similarity?: number;
};

function normalizeThreshold(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD;
return Math.min(1, Math.max(0, value));
}

function normalizeFingerprint(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim().toLowerCase();
return trimmed.length > 0 ? trimmed : null;
}

function tokenSet(fingerprint: string): Set<string> {
return new Set(
fingerprint
.split(/[\s:,]+/)
.map((token) => token.trim())
.filter(Boolean),
);
}

/** Token-set Jaccard similarity — deterministic and dependency-free for diff fingerprint comparison. */
export function fingerprintSimilarity(left: string, right: string): number {
const setLeft = tokenSet(normalizeFingerprint(left) ?? "");
const setRight = tokenSet(normalizeFingerprint(right) ?? "");
if (setLeft.size === 0 && setRight.size === 0) return 1;
if (setLeft.size === 0 || setRight.size === 0) return 0;
let intersection = 0;
for (const token of setLeft) {
if (setRight.has(token)) intersection += 1;
}
const union = setLeft.size + setRight.size - intersection;
return intersection / union;
}

function submissionTimeMs(value: string | null | undefined): number | null {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}

function asClaimMember(record: OwnSubmissionRecord): DuplicateClaimMember {
const number =
(typeof record.pullRequestNumber === "number" && Number.isFinite(record.pullRequestNumber)
? record.pullRequestNumber
: null) ??
(typeof record.issueNumber === "number" && Number.isFinite(record.issueNumber) ? record.issueNumber : null) ??
0;
return { number, linkedIssueClaimedAt: record.submittedAt };
}

function buildVerdict(
allowed: boolean,
eventType: GovernorLedgerEventType,
reason: string,
matchedSubmission?: OwnSubmissionRecord,
similarity?: number,
): SelfPlagiarismVerdict {
return {
allowed,
eventType,
reason,
...(matchedSubmission ? { matchedSubmission } : {}),
...(similarity !== undefined ? { similarity } : {}),
};
}

/**
* Compare a prospective PR fingerprint against the miner's own recent submissions. Fail closed when the
* candidate fingerprint or election timing is missing/ambiguous. When near-duplicates exist, only the
* earliest claimant wins — later submissions are throttled.
*/
export function selfPlagiarismCheck(
candidateFingerprint: SelfPlagiarismCandidate,
recentOwnSubmissions: readonly OwnSubmissionRecord[],
config: SelfPlagiarismConfig = DEFAULT_SELF_PLAGIARISM_CONFIG,
): SelfPlagiarismVerdict {
const threshold = normalizeThreshold(config.similarityThreshold);
const candidatePrint = normalizeFingerprint(candidateFingerprint.fingerprint);
if (candidatePrint === null) {
return buildVerdict(false, "denied", "missing_candidate_fingerprint");
}
if (submissionTimeMs(candidateFingerprint.submittedAt) === null) {
return buildVerdict(false, "denied", "missing_candidate_submitted_at");
}

let bestMatch: OwnSubmissionRecord | undefined;
let bestSimilarity = 0;
const nearDuplicates: OwnSubmissionRecord[] = [];

for (const prior of recentOwnSubmissions) {
const priorPrint = normalizeFingerprint(prior.fingerprint);
if (priorPrint === null) continue;
const similarity = fingerprintSimilarity(candidatePrint, priorPrint);
if (similarity >= threshold) {
nearDuplicates.push(prior);
if (similarity > bestSimilarity) {
bestSimilarity = similarity;
bestMatch = prior;
}
}
}

if (nearDuplicates.length === 0) {
return buildVerdict(true, "allowed", "distinct_from_recent_own_submissions");
}

for (const prior of nearDuplicates) {
if (submissionTimeMs(prior.submittedAt) === null) {
return buildVerdict(false, "denied", "missing_prior_submitted_at");
}
}

const candidateMember = asClaimMember(candidateFingerprint);
const siblingMembers = nearDuplicates.map(asClaimMember);
if (isDuplicateClusterWinnerByClaim(candidateMember, siblingMembers)) {
return buildVerdict(true, "allowed", "earliest_near_duplicate_claimant");
}

const winner =
resolveDuplicateClusterWinnerNumber(candidateMember, siblingMembers) ??
bestMatch?.pullRequestNumber ??
bestMatch?.issueNumber ??
null;
const matched =
bestMatch ??
nearDuplicates.find(
(prior) => prior.pullRequestNumber === winner || prior.issueNumber === winner,
) ??
nearDuplicates[0]!;

const matchedPrint = normalizeFingerprint(matched.fingerprint)!;
return buildVerdict(
false,
"throttled",
"near_duplicate_self_plagiarism",
matched,
bestSimilarity > 0 ? bestSimilarity : fingerprintSimilarity(candidatePrint, matchedPrint),
);
}

/** Governor-ledger row shape for an open_pr self-plagiarism decision (#2345 deliverable). */
export function buildSelfPlagiarismGovernorLedgerEvent(
repoFullName: string,
verdict: SelfPlagiarismVerdict,
): {
eventType: GovernorLedgerEventType;
repoFullName: string;
actionClass: string;
decision: string;
reason: string;
payload: Record<string, unknown>;
} {
const matched = verdict.matchedSubmission;
return {
eventType: verdict.eventType,
repoFullName,
actionClass: "open_pr",
decision: verdict.allowed ? "allow" : verdict.eventType === "throttled" ? "throttle" : "deny",
reason: verdict.reason,
payload: matched
? {
matchedRepoFullName: matched.repoFullName,
matchedPullRequestNumber: matched.pullRequestNumber ?? null,
matchedIssueNumber: matched.issueNumber ?? null,
matchedSubmittedAt: matched.submittedAt ?? null,
similarity: verdict.similarity ?? null,
}
: {},
};
}

/** Normalize a miner-goal-spec selfPlagiarism block (or bare threshold number) into engine config. */
export function resolveSelfPlagiarismConfig(raw: unknown): SelfPlagiarismConfig {
if (raw === undefined || raw === null) return { ...DEFAULT_SELF_PLAGIARISM_CONFIG };
if (typeof raw === "number") {
return { similarityThreshold: normalizeThreshold(raw) };
}
if (typeof raw === "object" && !Array.isArray(raw)) {
const record = raw as Record<string, unknown>;
return {
similarityThreshold: normalizeThreshold(
typeof record.similarityThreshold === "number"
? record.similarityThreshold
: DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD,
),
};
}
return { ...DEFAULT_SELF_PLAGIARISM_CONFIG };
}
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export {
} from "./track-record-summary.js";
export * from "./governor/rate-limit.js";
export * from "./governor/budget-cap.js";
export * from "./governor/self-plagiarism.js";
export {
GOVERNOR_LEDGER_EVENT_TYPES,
normalizeGovernorLedgerEvent,
Expand Down
51 changes: 50 additions & 1 deletion packages/gittensory-engine/src/miner-goal-spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { parse as parseYaml } from "yaml";

import {
DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD,
resolveSelfPlagiarismConfig,
} from "./governor/self-plagiarism.js";

// MinerGoalSpec (#2293 / #2301). The type surface for `.gittensory-miner.yml` — the per-repo config a
// maintainer/repo-owner drops in to tell an autonomous miner what to look for and how to behave when targeting
// their repo. This is the MINER-side analogue of the review-side `.gittensory.yml` focus manifest (see
Expand All @@ -23,6 +28,12 @@ export type FeasibilityGatePolicy = {
suppressedReasons: readonly string[];
};

/** Per-repo self-plagiarism throttle tuning for Governor open_pr (#2345). */
export type SelfPlagiarismPolicy = {
/** Jaccard similarity threshold in [0, 1] for near-duplicate diff fingerprints. Default: 0.85. */
similarityThreshold: number;
};

/** Per-repo miner configuration parsed from `.gittensory-miner.yml`. See {@link DEFAULT_MINER_GOAL_SPEC}. */
export type MinerGoalSpec = {
/**
Expand Down Expand Up @@ -67,6 +78,10 @@ export type MinerGoalSpec = {
* Default: { enabled: true, suppressedReasons: [] }.
*/
feasibilityGate: FeasibilityGatePolicy;
/**
* Self-plagiarism throttle consulted before open_pr (#2345). Default: { similarityThreshold: 0.85 }.
*/
selfPlagiarism: SelfPlagiarismPolicy;
};

/** The tolerant parser result for `.gittensory-miner.yml`: the normalized spec plus parse warnings and whether the
Expand Down Expand Up @@ -96,6 +111,7 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly<MinerGoalSpec> = Object.freeze({
maxConcurrentClaims: 1,
issueDiscoveryPolicy: "neutral",
feasibilityGate: Object.freeze({ enabled: true, suppressedReasons: Object.freeze([]) }),
selfPlagiarism: Object.freeze({ similarityThreshold: DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD }),
});

const MAX_MINER_GOAL_SPEC_BYTES = 32_768;
Expand All @@ -113,6 +129,7 @@ function cloneDefaultMinerGoalSpec(): MinerGoalSpec {
enabled: DEFAULT_MINER_GOAL_SPEC.feasibilityGate.enabled,
suppressedReasons: [...DEFAULT_MINER_GOAL_SPEC.feasibilityGate.suppressedReasons],
},
selfPlagiarism: { ...DEFAULT_MINER_GOAL_SPEC.selfPlagiarism },
};
}

Expand Down Expand Up @@ -190,6 +207,31 @@ function normalizeFeasibilityGatePolicy(
};
}

function normalizeSelfPlagiarismPolicy(
value: unknown,
field: string,
fallback: SelfPlagiarismPolicy,
warnings: string[],
): SelfPlagiarismPolicy {
if (value === undefined || value === null) return fallback;
if (typeof value !== "object" || Array.isArray(value)) {
warnings.push(`MinerGoalSpec field "${field}" must be a mapping; falling back to defaults.`);
return fallback;
}
const resolved = resolveSelfPlagiarismConfig(value);
const record = value as Record<string, unknown>;
if (
record.similarityThreshold !== undefined &&
typeof record.similarityThreshold !== "number"
) {
warnings.push(
`MinerGoalSpec field "${field}.similarityThreshold" must be a number; falling back to ${fallback.similarityThreshold}.`,
);
return fallback;
}
return resolved;
}

function normalizePositiveInteger(value: unknown, field: string, fallback: number, warnings: string[]): number {
if (value === undefined || value === null) return fallback;
if (typeof value !== "number" || !Number.isFinite(value)) {
Expand Down Expand Up @@ -224,7 +266,8 @@ function hasConfiguredGoalFields(spec: MinerGoalSpec): boolean {
spec.maxConcurrentClaims !== DEFAULT_MINER_GOAL_SPEC.maxConcurrentClaims ||
spec.issueDiscoveryPolicy !== DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy ||
spec.feasibilityGate.enabled !== DEFAULT_MINER_GOAL_SPEC.feasibilityGate.enabled ||
spec.feasibilityGate.suppressedReasons.length > 0
spec.feasibilityGate.suppressedReasons.length > 0 ||
spec.selfPlagiarism.similarityThreshold !== DEFAULT_MINER_GOAL_SPEC.selfPlagiarism.similarityThreshold
);
}

Expand Down Expand Up @@ -271,6 +314,12 @@ export function parseMinerGoalSpec(raw: unknown): ParsedMinerGoalSpec {
DEFAULT_MINER_GOAL_SPEC.feasibilityGate,
warnings,
),
selfPlagiarism: normalizeSelfPlagiarismPolicy(
record.selfPlagiarism,
"selfPlagiarism",
DEFAULT_MINER_GOAL_SPEC.selfPlagiarism,
warnings,
),
};
if (!hasConfiguredGoalFields(spec)) {
warnings.push("MinerGoalSpec contained no recognized non-default goal fields; falling back to safe defaults.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ test("parseMinerGoalSpec: valid raw config normalizes every field and keeps non-
maxConcurrentClaims: 2,
issueDiscoveryPolicy: "encouraged",
feasibilityGate: { enabled: false, suppressedReasons: ["duplicate_cluster_high"] },
selfPlagiarism: { similarityThreshold: 0.85 },
});
assert.deepEqual(parsed.warnings, []);
});
Expand Down Expand Up @@ -155,6 +156,7 @@ test("parseMinerGoalSpec: malformed fields fall back independently with targeted
maxConcurrentClaims: 1,
issueDiscoveryPolicy: "neutral",
feasibilityGate: { enabled: true, suppressedReasons: [] },
selfPlagiarism: { similarityThreshold: 0.85 },
});
const warningText = parsed.warnings.join(" ");
assert.match(warningText, /minerEnabled/i);
Expand Down Expand Up @@ -183,6 +185,7 @@ test("parseMinerGoalSpec: unknown-only or default-only content stays absent with
maxConcurrentClaims: 1,
issueDiscoveryPolicy: "neutral",
feasibilityGate: { enabled: true, suppressedReasons: [] },
selfPlagiarism: { similarityThreshold: 0.85 },
});
assert.equal(explicitDefaults.present, false);
assert.deepEqual(explicitDefaults.spec, DEFAULT_MINER_GOAL_SPEC);
Expand Down
Loading