feat(autofix): post a takeover milestone digest every tenth pushed round (#8046)

* feat(autofix): post a takeover milestone digest every tenth pushed round

The takeover round cap (100) bounds runaway but carries no signal about
when a human should step in: #7469 ground to round 12 over seven days
of takeover with fifteen pushes, and the only place that trajectory was
visible was the Actions logs.

Every 10th pushed round under takeover, the report step now posts a
window-scoped census on the PR itself — pushed fixes, no-change
reviews, timeouts, rejected attempts, base updates — plus the three
options (keep going / split or reduce / release takeover). The digest
is a separate comment with its own autofix-milestone marker and no
autofix-eval marker, so every census (round, consecutive-failure,
watermark) ignores it and the feedback filters keep it out of the
agent's prompt. Posting is best-effort: a digest failure never fails a
good push.

The rejected-attempt count matches both the current and the reworded
gate-rejection headline so the census cannot silently zero; base
updates carry no win= field and are windowed by timestamp instead (the
window key is the engage ack's created_at).

* feat(autofix): milestone digest review follow-ups

- Residual bucket: every outcome the four buckets missed (crash, model
  error, gate error, infra) now lands in 'other round(s)' — a window
  that burned 80% of its budget on crashes was rendering as four zeros,
  QUIETER than a healthy window, the inverse of the digest's premise.
- Crossing trigger: fire on the first pushed round once 10+ rounds have
  accumulated since the last digest in this window, instead of an exact
  %10 hit — failure rounds advance the counter too, so push@9/crash@10/
  push@11 skipped the digest forever on exactly the failure-heavy PRs
  it exists for.
- The success log is chained to the post; a failed comment no longer
  logs 'posted' after its own warning.
- WINDOW=none says 'since the PR opened (no counting window yet)'
  instead of claiming a current window while counting all time.
- A census that parses zero window markers at round 10+ skips the
  digest instead of posting a fabricated all-zero one.
- autofix-milestone joins BOT_COMMENT_FILTER (marker inventory).
- The timeout needle anchors on the verbatim emitted headline.
- The pin-only test became a behavioral replay: the digest block runs
  under bash with a stubbed gh over fixture ic.json histories — bucket
  counts, residual loudness, crossing suppression/resumption, old-window
  isolation, none-window phrasing, non-pushed/non-takeover gating, and
  the empty-census skip. The OUTCOME == fixed conjunct is pinned.

* test(autofix): cross-pin digest census needles to headline emission sites (#8046)

* test(autofix): widen rejected-headline cross-pin to match #8044 reword (#8046)

* test(autofix): behaviorally cover the digest comment-failure branch (#8046)

---------

Co-authored-by: verify <verify@local>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
This commit is contained in:
Shaojin Wen 2026-07-30 10:40:55 +08:00 committed by GitHub
parent 36fe53d421
commit 25f5147677
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 366 additions and 4 deletions

View file

@ -2281,7 +2281,7 @@ jobs:
# bot's own eval markers, and known non-actionable bot comments
# (triage stages, coverage reports, legacy suggestion summaries,
# force-push reminders).
BOT_COMMENT_FILTER='<!-- (autofix-eval|autofix-rearm|autofix-base-updated|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) '
BOT_COMMENT_FILTER='<!-- (autofix-eval|autofix-rearm|autofix-base-updated|autofix-milestone|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) '
# Command-style comments (@qwen-code /takeover, /triage, /review …)
# are INSTRUCTIONS to tooling, not review feedback on the diff:
# counting them as actionable burns a full agent cycle to post a
@ -3507,6 +3507,89 @@ jobs:
gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md"
# Takeover milestone digest — roughly every 10 rounds. The takeover
# cap (100) bounds runaway but says nothing about when a human
# should step in: #7469 ground to round 12 over 7 days with the
# only "this is burning budget" signal buried in Actions logs.
# Once 10+ rounds accumulate since the last digest, surface a
# window-scoped census on the PR so the maintainer who engaged it
# can decide: keep going, split the PR, or release. A SEPARATE
# comment with its OWN marker and WITHOUT the autofix-eval marker:
# every census (round, consec, watermark) selects on autofix-eval,
# so this comment is invisible to all of them, and the feedback
# filters drop bot comments, so the agent never sees it either.
# Best-effort: a digest failure must never fail a good push.
if [[ "${OUTCOME}" == "fixed" && "${MAX_ROUNDS}" == "${TAKEOVER_MAX_ROUNDS}" ]] \
&& [[ "${NEXT_ROUND}" -ge 10 && -f "${WORKDIR}/ic.json" ]]; then
# Crossing trigger, not an equality test: failure rounds also
# advance the round counter, so `push@9, crash@10, push@11`
# would skip an exact %10 check forever — and a failure-heavy
# PR is the very PR the digest exists for. Post on the first
# PUSHED round once 10+ rounds have accumulated since the last
# digest in THIS window (or since the window opened).
MS_LAST="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" '
[ .[] | select((.user.login // "") == $ab) | (.body // "")
| [ scan("<!-- autofix-milestone round=([0-9]+) win=([^ ]+) -->") ] | .[]
| select(.[1] == $win) | (.[0] | tonumber) ]
| max // 0' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)"
if [[ "$(( NEXT_ROUND - MS_LAST ))" -ge 10 ]]; then
WIN_HEADS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" '
[.[] | select((.user.login // "") == $ab)
| select((.body // "") | contains("<!-- autofix-eval "))
| select(
((.body // "") | contains("win=" + $win + " -->"))
or ($win == "none" and (((.body // "") | contains("win=")) | not)))]
| sort_by(.created_at) | .[]
| (.body | gsub("\r"; "") | split("\n")[0])' "${WORKDIR}/ic.json" 2> /dev/null || true)"
if [[ -z "${WIN_HEADS}" ]]; then
# Reaching round 10+ with zero window markers means the
# parse failed (prior markers must exist to be here) — a
# fabricated all-zero census is worse than no digest.
echo "::warning::milestone census found no window markers on #${PR}; skipping the digest"
else
N_PUSHED="$(grep -c 'Addressed the latest review feedback' <<< "${WIN_HEADS}" || true)"
# This round's own marker was posted just above but ic.json
# predates it — count it in by hand.
N_PUSHED=$(( N_PUSHED + 1 ))
N_NOOP="$(grep -c 'no changes needed' <<< "${WIN_HEADS}" || true)"
# Needle matches the emitted headline verbatim — first
# lines can embed provider error text.
N_TIMEOUT="$(grep -c 'AutoFix ran out of time before finishing' <<< "${WIN_HEADS}" || true)"
# Both wordings of the gate-rejection handoff, past and
# present — the census must not silently zero when the
# headline is reworded.
N_REJECTED="$(grep -cE 'Could not (address the latest feedback|produce a passing fix)' <<< "${WIN_HEADS}" || true)"
# Every other outcome (crash, model error, gate error,
# infra) lands in a residual bucket: a window that burned
# 80% of its budget on crashes must be the LOUDEST line in
# the digest, not four zeros quieter than a healthy one.
N_TOTAL=$(( $(grep -c . <<< "${WIN_HEADS}" || true) + 1 ))
N_OTHER=$(( N_TOTAL - N_PUSHED - N_NOOP - N_TIMEOUT - N_REJECTED ))
(( N_OTHER < 0 )) && N_OTHER=0
# Base updates carry their own marker with no win= field;
# their window is recovered by timestamp (the window key IS
# the engage ack's created_at — 'none' means count all,
# and the header says so).
N_BASE="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" '
[.[] | select((.user.login // "") == $ab)
| select((.body // "") | contains("<!-- autofix-base-updated -->"))
| select($win == "none" or ((.created_at // "") > $win))]
| length' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)"
WIN_DESC='in the current window'
WIN_DESC_ZH='当前窗口'
if [[ "${WINDOW:-none}" == 'none' ]]; then
WIN_DESC='since the PR opened (no counting window yet)'
WIN_DESC_ZH='自 PR 创建以来(尚无计数窗口)'
fi
if gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '📊 Takeover milestone — round %s/%s, %s. Census: %s pushed fix(es), %s no-change review(s), %s timeout(s), %s rejected attempt(s), %s other round(s) (crash / model error / gate error / infra), %s base update(s).\n\nThis many rounds deserves a human look. Options: keep going (fine — nothing changes), split or reduce the PR if rounds keep accumulating, or release takeover (remove the `%s` label or comment `%s stop`). Management continues unchanged unless you act.\n\n<details>\n<summary>中文说明</summary>\n\n📊 接管里程碑 —— 第 %s/%s 轮(%s。统计推送修复 %s 次、审阅无需改动 %s 次、超时 %s 次、验证拒绝 %s 次、其他轮次(崩溃/模型错误/门错误/infra%s 次、base 更新 %s 次。\n\n轮次到这个量值得人工看一眼。可选继续无需操作若轮次持续累积考虑拆分或缩减 PR或释放接管移除 `%s` 标签或评论 `%s stop`)。不操作则托管照常继续。\n\n</details>\n\n<!-- autofix-milestone round=%s win=%s -->' "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC_ZH}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${WINDOW:-none}")"; then
echo "📊 milestone digest posted on #${PR} (round ${NEXT_ROUND})"
else
echo "::warning::milestone digest failed to post on PR #${PR}; the round report above already landed"
fi
fi
fi
fi
{
ISSUE_REF=""
[[ "${ISSUE}" != "${PR}" ]] && ISSUE_REF=" (issue #${ISSUE})"

View file

@ -4609,6 +4609,285 @@ describe('qwen-autofix workflow', () => {
expect(workflow).not.toContain('cat > "${proxy_script}"');
});
it('posts a takeover milestone digest as rounds accumulate, with a residual bucket', () => {
// The takeover cap (100) bounds runaway but says nothing about when a
// human should step in: #7469 ground to round 12 over 7 days with the
// only budget signal buried in Actions logs. Once 10+ rounds accumulate
// since the last digest, a window-scoped census lands on the PR itself.
// Digest fires only on takeover PRs and only for PUSHED rounds…
expect(pushAndReportStep).toContain('"${OUTCOME}" == "fixed"');
expect(pushAndReportStep).toContain(
'"${MAX_ROUNDS}" == "${TAKEOVER_MAX_ROUNDS}"',
);
// …on a CROSSING trigger, not an equality test: failure rounds also
// advance the counter, so `push@9, crash@10, push@11` would skip an
// exact %10 forever — on exactly the failure-heavy PR the digest
// exists for.
expect(pushAndReportStep).toContain('"${NEXT_ROUND}" -ge 10');
expect(pushAndReportStep).toContain('$(( NEXT_ROUND - MS_LAST ))');
// Its own marker, NOT autofix-eval: every census (round, consec,
// watermark) selects on autofix-eval, so the digest must stay
// invisible to all of them; the feedback filters drop bot comments,
// so the agent never reads it as feedback either.
expect(pushAndReportStep).toContain('<!-- autofix-milestone round=');
const milestone = pushAndReportStep.match(
/printf '[^']*autofix-milestone[^']*'/,
)?.[0];
expect(milestone).toBeTruthy();
expect(milestone).not.toContain('autofix-eval');
expect(milestone).toContain('<summary>中文说明</summary>');
// …and the marker inventory stays complete.
expect(workflow).toContain(
'autofix-base-updated|autofix-milestone|qwen-triage',
);
// Behavioral replay: extract the digest block and run it under bash
// with a stubbed gh against fixture ic.json histories. String pins
// alone cannot catch a census that silently zeroes.
const digestBlock = pushAndReportStep.match(
/if \[\[ "\$\{OUTCOME\}" == "fixed" && "\$\{MAX_ROUNDS\}" == "\$\{TAKEOVER_MAX_ROUNDS\}" \]\][\s\S]*?\n {10}fi\n/,
)?.[0];
expect(digestBlock).toBeTruthy();
// Cross-pin: each census grep needle must match the actual headline
// emission site, so a reword that updates one but not the other fails
// here — not silently in production.
const emitPush = pushAndReportStep.match(
/echo "(🤖 Addressed the latest review feedback[^"]*)"/,
)?.[1];
const emitNoop = pushAndReportStep.match(
/echo "(🤖 Reviewed the latest feedback — no changes needed[^"]*)"/,
)?.[1];
const emitTimeout = reviewAddressReportStep.match(
/CAUSE="(ran out of time before finishing[^"]*)"/,
)?.[1];
const emitRejected = reviewAddressReportStep.match(
/HEADLINE="(🤖 Could not (?:address the latest feedback|produce a passing fix)[^"]*)"/,
)?.[1];
expect(emitPush).toBeTruthy();
expect(emitNoop).toBeTruthy();
expect(emitTimeout).toBeTruthy();
expect(emitRejected).toBeTruthy();
const needlePushed = digestBlock.match(/N_PUSHED=.*grep -c '([^']*)'/)?.[1];
const needleNoop = digestBlock.match(/N_NOOP=.*grep -c '([^']*)'/)?.[1];
const needleTimeout = digestBlock.match(
/N_TIMEOUT=.*grep -c '([^']*)'/,
)?.[1];
const needleRejected = digestBlock.match(
/N_REJECTED=.*grep -cE '([^']*)'/,
)?.[1];
expect(needlePushed).toBeTruthy();
expect(needleNoop).toBeTruthy();
expect(needleTimeout).toBeTruthy();
expect(needleRejected).toBeTruthy();
expect(emitPush).toContain(needlePushed);
expect(emitNoop).toContain(needleNoop);
expect(`🤖 AutoFix ${emitTimeout}`).toContain(needleTimeout);
expect(emitRejected).toMatch(new RegExp(needleRejected));
const HEADS = {
push: '🤖 Addressed the latest review feedback (round 2/100). What changed…',
noop: '🤖 Reviewed the latest feedback — no changes needed. Why…',
timeout:
'🤖 AutoFix ran out of time before finishing (timeout (3000000ms)) (attempt 2/100) — it will retry on the next scan.',
rejectedOld:
'🤖 Could not address the latest feedback automatically (round 3/100). A human should take over this PR.',
rejectedNew:
'🤖 Could not produce a passing fix for this feedback (round 4/100) — the verification gate rejected the attempt.',
crash:
'🤖 AutoFix crashed before it could evaluate the feedback (attempt 2/100) — it will retry on the next scan.',
gate: '🤖 AutoFix hit a verification-gate error before reaching a verdict (attempt 3/100) — it will retry on the next scan.',
};
const K = '2026-07-01T00:00:00Z';
const evalC = (head, win, at, login = 'qwen-code-dev-bot') => ({
user: { login },
created_at: at,
body: `${head}\n<!-- autofix-eval ts=x acted=false round=1${win ? ` win=${win}` : ''} -->`,
});
const baseC = (at) => ({
user: { login: 'qwen-code-dev-bot' },
created_at: at,
body: '🔀 Base updated: …\n<!-- autofix-base-updated -->',
});
const msC = (round, win, at) => ({
user: { login: 'qwen-code-dev-bot' },
created_at: at,
body: `📊 …\n<!-- autofix-milestone round=${round} win=${win} -->`,
});
const runDigest = (
comments,
{
nextRound = 10,
window = K,
outcome = 'fixed',
maxRounds = '100',
commentExit = 0,
} = {},
) => {
const dir = mkdtempSync(join(tmpdir(), 'milestone-'));
try {
writeFileSync(join(dir, 'ic.json'), JSON.stringify(comments));
const bin = join(dir, 'bin');
mkdirSync(bin);
const commentBody =
commentExit === 0
? `printf '%s' "$7" > ${JSON.stringify(join(dir, 'digest.md'))}; exit 0`
: `exit ${commentExit}`;
writeFileSync(
join(bin, 'gh'),
`#!/usr/bin/env bash\nif [[ "$1" == 'pr' && "$2" == 'comment' ]]; then ${commentBody}; fi\nexit 1\n`,
);
chmodSync(join(bin, 'gh'), 0o755);
const log = execFileSync(
'bash',
['-c', `set -euo pipefail\n${digestBlock.replace(/\n {10}/g, '\n')}`],
{
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
WORKDIR: dir,
OUTCOME: outcome,
NEXT_ROUND: String(nextRound),
MAX_ROUNDS: maxRounds,
TAKEOVER_MAX_ROUNDS: '100',
WINDOW: window,
AUTOFIX_BOT: 'qwen-code-dev-bot',
REPO: 'o/r',
PR: '1',
TAKEOVER_LABEL: 'autofix/takeover',
TAKEOVER_COMMAND: '@qwen-code /takeover',
},
encoding: 'utf8',
},
);
const digestPath = join(dir, 'digest.md');
return {
log,
body: existsSync(digestPath) ? readFileSync(digestPath, 'utf8') : '',
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
// Mixed healthy history: counts land in the right buckets, an
// old-window push and a HUMAN quoting a marker verbatim are excluded,
// both rejection wordings count, base updates window by timestamp.
const mixed = runDigest([
evalC(HEADS.push, K, '2026-07-02T00:00:00Z'),
evalC(HEADS.push, K, '2026-07-03T00:00:00Z'),
evalC(HEADS.push, K, '2026-07-04T00:00:00Z'),
evalC(HEADS.push, K, '2026-07-05T00:00:00Z'),
evalC(HEADS.push, K, '2026-07-06T00:00:00Z'),
evalC(HEADS.noop, K, '2026-07-07T00:00:00Z'),
evalC(HEADS.timeout, K, '2026-07-08T00:00:00Z'),
evalC(HEADS.rejectedOld, K, '2026-07-09T00:00:00Z'),
evalC(HEADS.rejectedNew, K, '2026-07-10T00:00:00Z'),
evalC(HEADS.push, '2026-05-01T00:00:00Z', '2026-06-01T00:00:00Z'),
evalC(HEADS.push, K, '2026-07-11T00:00:00Z', 'some-human'),
baseC('2026-07-12T00:00:00Z'),
baseC('2026-05-02T00:00:00Z'),
]);
expect(mixed.body).toContain(
'6 pushed fix(es), 1 no-change review(s), 1 timeout(s), 2 rejected attempt(s), 0 other round(s)',
);
expect(mixed.body).toContain('1 base update(s)');
expect(mixed.body).toContain('round 10/100, in the current window');
expect(mixed.body).toContain(
'<!-- autofix-milestone round=10 win=2026-07-01T00:00:00Z -->',
);
expect(mixed.log).toContain('milestone digest posted');
// Failure-heavy window: crashes and gate errors land in the residual
// bucket and make it the LOUDEST line, not four zeros quieter than a
// healthy window.
const grim = runDigest([
evalC(HEADS.push, K, '2026-07-02T00:00:00Z'),
evalC(HEADS.crash, K, '2026-07-03T00:00:00Z'),
evalC(HEADS.crash, K, '2026-07-04T00:00:00Z'),
evalC(HEADS.crash, K, '2026-07-05T00:00:00Z'),
evalC(HEADS.crash, K, '2026-07-06T00:00:00Z'),
evalC(HEADS.gate, K, '2026-07-07T00:00:00Z'),
evalC(HEADS.gate, K, '2026-07-08T00:00:00Z'),
evalC(HEADS.gate, K, '2026-07-09T00:00:00Z'),
evalC(HEADS.gate, K, '2026-07-10T00:00:00Z'),
]);
expect(grim.body).toContain(
'2 pushed fix(es), 0 no-change review(s), 0 timeout(s), 0 rejected attempt(s), 8 other round(s)',
);
// Crossing trigger: a digest at round 10 suppresses round 12 but not
// round 20; a failure at the exact multiple no longer loses the digest.
const suppressed = runDigest(
[
evalC(HEADS.push, K, '2026-07-02T00:00:00Z'),
msC(10, K, '2026-07-03T00:00:00Z'),
],
{ nextRound: 12 },
);
expect(suppressed.body).toBe('');
const dueAgain = runDigest(
[
evalC(HEADS.push, K, '2026-07-02T00:00:00Z'),
msC(10, K, '2026-07-03T00:00:00Z'),
],
{ nextRound: 20 },
);
expect(dueAgain.body).toContain('round 20/100');
// An old-window milestone marker does not suppress a fresh window.
const freshWindow = runDigest(
[
evalC(HEADS.push, K, '2026-07-02T00:00:00Z'),
msC(11, '2026-05-01T00:00:00Z', '2026-07-03T00:00:00Z'),
],
{ nextRound: 11 },
);
expect(freshWindow.body).toContain('round 11/100');
// WINDOW=none says what it counts instead of claiming a window.
const noWindow = runDigest(
[evalC(HEADS.push, null, '2026-07-02T00:00:00Z')],
{ window: 'none' },
);
expect(noWindow.body).toContain('since the PR opened');
// Non-pushing outcomes and non-takeover caps never digest.
expect(
runDigest([evalC(HEADS.push, K, '2026-07-02T00:00:00Z')], {
outcome: 'noop',
}).body,
).toBe('');
expect(
runDigest([evalC(HEADS.push, K, '2026-07-02T00:00:00Z')], {
maxRounds: '10',
}).body,
).toBe('');
// A census that parses zero window markers at round 10+ posts NOTHING
// rather than a fabricated all-zero digest.
const empty = runDigest([]);
expect(empty.body).toBe('');
expect(empty.log).toContain('skipping the digest');
// Best-effort is behavioral, not just string-pinned: when `gh pr
// comment` fails, the block must still return normally (no throw under
// the step's `set -e` lineage) and only warn — a good push must never
// go red over a failed digest.
const commentFailed = runDigest(
[evalC(HEADS.push, K, '2026-07-02T00:00:00Z')],
{ commentExit: 1 },
);
expect(commentFailed.body).toBe('');
expect(commentFailed.log).toContain(
'::warning::milestone digest failed to post on PR #1',
);
// Best-effort stays pinned: the success log is chained to the post
// (no unconditional "posted" after a failed comment), the failure
// path only warns.
expect(pushAndReportStep).toMatch(
/then\n\s+echo "📊 milestone digest posted/,
);
expect(pushAndReportStep).toContain('milestone digest failed to post');
});
it('pushes autofix branches without rewriting remote history', () => {
expect(workflow).not.toMatch(/\bgit push\b[^\n]*--force(?:-with-lease)?/);
// No bare -f / +refspec force forms either. (--no-verify is NOT a force
@ -6191,13 +6470,13 @@ describe('qwen-autofix workflow', () => {
expect(workflow).toContain("RETRY_COMMAND: '@qwen-code /retry'");
expect(workflow).toContain('<!-- autofix-rearm -->');
expect(workflow).toContain(
'<!-- (autofix-eval|autofix-rearm|autofix-base-updated|qwen-triage|',
'<!-- (autofix-eval|autofix-rearm|autofix-base-updated|autofix-milestone|qwen-triage|',
);
// Verify all four filter sites (scan + 3 address) include autofix-rearm;
// the scan site also carries autofix-base-updated.
// the scan site also carries autofix-base-updated + autofix-milestone.
const filterMatches = [
...workflow.matchAll(
/autofix-eval\|autofix-rearm\|(autofix-base-updated\|)?qwen-triage/g,
/autofix-eval\|autofix-rearm\|(autofix-base-updated\|autofix-milestone\|)?qwen-triage/g,
),
];
expect(filterMatches.length).toBeGreaterThanOrEqual(4);