From abf53ec182614757794fec427ed79e3f5a118801 Mon Sep 17 00:00:00 2001 From: alexanderludwig Date: Thu, 27 Aug 2026 18:12:23 +0200 Subject: [PATCH 01/17] feat(contracts): import transparent verification contracts --- .../interfaces/IAntseedVerification.sol | 60 ++++ .../contracts/test/mocks/MockDeposits.sol | 20 ++ .../AntseedVerifierPointsPolicy.t.sol | 122 +++++++ .../AntseedVerifierRegistry.t.sol | 307 +++++++++++++++++ .../verification/AntseedVerifierRewards.t.sol | 217 ++++++++++++ .../verification/AntseedVerification.sol | 312 ++++++++++++++++++ 6 files changed, 1038 insertions(+) create mode 100644 packages/contracts/interfaces/IAntseedVerification.sol create mode 100644 packages/contracts/test/mocks/MockDeposits.sol create mode 100644 packages/contracts/test/verification/AntseedVerifierPointsPolicy.t.sol create mode 100644 packages/contracts/test/verification/AntseedVerifierRegistry.t.sol create mode 100644 packages/contracts/test/verification/AntseedVerifierRewards.t.sol create mode 100644 packages/contracts/verification/AntseedVerification.sol diff --git a/packages/contracts/interfaces/IAntseedVerification.sol b/packages/contracts/interfaces/IAntseedVerification.sol new file mode 100644 index 000000000..986f3ad56 --- /dev/null +++ b/packages/contracts/interfaces/IAntseedVerification.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IAntseedEmissionsGate } from "./IAntseedEmissionsGate.sol"; +import { IAntseedPointsPolicy } from "./IAntseedPointsPolicy.sol"; +import { IAntseedRegistry } from "./IAntseedRegistry.sol"; + +interface IAntseedVerification is IAntseedPointsPolicy { + struct VerificationResult { + uint256 agentId; + bytes32 serviceHash; + Verdict verdict; + uint16 modelShareBps; + } + + enum Verdict { + UNKNOWN, + SAME, + DIFF, + UNDETERMINED + } + + function registry() external view returns (IAntseedRegistry); + function emissionsGate() external view returns (IAntseedEmissionsGate); + function firstRewardedEpoch() external view returns (uint256); + function approvedVerifiers(address verifier) external view returns (bool); + + /// @notice Maximum verifier credit weight per epoch, stored in six-decimal USD micros. + /// @dev One credit equals $1 = 1_000_000 units; for example, $1.20 = 1_200_000 units. + function maxCreditUsdMicrosPerVerifierPerEpoch() external view returns (uint64); + + function setVerifier(address verifier, bool approved) external; + function setMaxCreditUsdMicrosPerVerifierPerEpoch(uint64 maximum) external; + + /// @notice Submits all audited seller results for one model as the verifier credit weight. + /// @dev `evidenceHash` is the canonical hash of the model bundle evidence and doubles as the + /// replay-protection key. `evidenceUri` optionally locates the public evidence package. + /// `totalAuditCostUsdMicros` preserves fractional credits exactly; no whole-dollar rounding occurs. + function submitVerificationBundle( + uint256 expectedEpoch, + uint64 totalAuditCostUsdMicros, + bytes32 evidenceHash, + string calldata evidenceUri, + VerificationResult[] calldata results + ) external; + + function isVerificationSubmitted(bytes32 evidenceHash) external view returns (bool); + + function epochCreditUsdMicros(uint256 epoch, address verifier) external view returns (uint256); + function epochTotalCreditUsdMicros(uint256 epoch) external view returns (uint256); + function currentEpoch() external view returns (uint256); + function agentPointsPenaltyBps(uint256 agentId) external view returns (uint16); + + function claimVerifierReward(uint256 epoch) external; + function settleEpochRemainder(uint256 epoch) external returns (uint256 burnedAmount, uint256 reserveAmount); + function pendingVerifierReward(uint256 epoch, address verifier) external view returns (uint256); + function verifierEpochBudget(uint256 epoch) external view returns (uint256); + function verifierEpochTotalCreditUsdMicros(uint256 epoch) external view returns (uint256); + function epochRemainderSettled(uint256 epoch) external view returns (bool); +} diff --git a/packages/contracts/test/mocks/MockDeposits.sol b/packages/contracts/test/mocks/MockDeposits.sol new file mode 100644 index 000000000..bdfeb816b --- /dev/null +++ b/packages/contracts/test/mocks/MockDeposits.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @notice Minimal AntseedDeposits stand-in for verifier and policy tests. +contract MockDeposits { + address public usdc; + mapping(address buyer => address operator) private _operators; + + function setUsdc(address usdc_) external { + usdc = usdc_; + } + + function setOperator(address buyer, address operator) external { + _operators[buyer] = operator; + } + + function getOperator(address buyer) external view returns (address) { + return _operators[buyer]; + } +} diff --git a/packages/contracts/test/verification/AntseedVerifierPointsPolicy.t.sol b/packages/contracts/test/verification/AntseedVerifierPointsPolicy.t.sol new file mode 100644 index 000000000..2c8515958 --- /dev/null +++ b/packages/contracts/test/verification/AntseedVerifierPointsPolicy.t.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; + +import { AntseedRegistry } from "../../core/AntseedRegistry.sol"; +import { AntseedEmissionsGate } from "../../emissions/AntseedEmissionsGate.sol"; +import { IAntseedVerification } from "../../interfaces/IAntseedVerification.sol"; +import { AntseedVerification } from "../../verification/AntseedVerification.sol"; +import { MockERC8004Registry } from "../mocks/MockERC8004Registry.sol"; + +contract MockPointsPolicyStaking { + mapping(address seller => uint256 agentId) public getAgentId; + + function setAgentId(address seller, uint256 agentId) external { + getAgentId[seller] = agentId; + } +} + +contract EmptyPointsPolicyTarget { } + +contract AntseedVerifierPointsPolicyTest is Test { + uint256 private constant GENESIS = 1_775_728_461; + uint256 private constant AGENT_ID = 42; + uint256 private constant RAW_POINTS = 1_000; + bytes32 private constant SERVICE_HASH = keccak256("gpt-5.6-sol"); + + address private seller = address(0xA11CE); + address private buyer = address(0xB0B); + address private verifier = address(0xF00D); + + AntseedRegistry private registry; + MockPointsPolicyStaking private staking; + AntseedVerification private verification; + + function setUp() public { + vm.warp(GENESIS + 8 days); + registry = new AntseedRegistry(); + registry.setTeamWallet(address(0x1111)); + registry.setProtocolReserve(address(0x2222)); + MockERC8004Registry identity = new MockERC8004Registry(); + registry.setIdentityRegistry(address(identity)); + staking = new MockPointsPolicyStaking(); + staking.setAgentId(seller, AGENT_ID); + registry.setStaking(address(staking)); + AntseedEmissionsGate gate = new AntseedEmissionsGate(address(0x1111), address(0x2222), 15_000, 15_000); + verification = new AntseedVerification(address(registry), address(gate)); + verification.setVerifier(verifier, true); + identity.setOwner(AGENT_ID, seller); + } + + function test_noPenaltyPassesAllPointsThrough() public view { + _assertPoints(RAW_POINTS, RAW_POINTS, RAW_POINTS); + } + + function test_proportionalPenaltyDiscountsSellerPoints() public { + _submit(IAntseedVerification.Verdict.DIFF, 2_500, keccak256("diff")); + _assertPoints(RAW_POINTS, 750, RAW_POINTS); + } + + function test_penaltyStaysActiveUntilCleared() public { + _submit(IAntseedVerification.Verdict.DIFF, 2_500, keccak256("diff")); + _assertPoints(RAW_POINTS, 750, RAW_POINTS); + _submit(IAntseedVerification.Verdict.SAME, 0, keccak256("same")); + _assertPoints(RAW_POINTS, RAW_POINTS, RAW_POINTS); + } + + function test_fullPenaltyZeroesSellerPoints() public { + _submit(IAntseedVerification.Verdict.DIFF, 10_000, keccak256("full")); + _assertPoints(RAW_POINTS, 0, RAW_POINTS); + } + + function test_unknownSellerPassesAllPointsThrough() public view { + (uint256 sellerPoints, uint256 buyerPoints) = + verification.points(bytes32(0), buyer, address(0xCAFE), RAW_POINTS); + assertEq(sellerPoints, RAW_POINTS); + assertEq(buyerPoints, RAW_POINTS); + } + + function test_brokenStakingReadPassesAllPointsThrough() public { + registry.setStaking(address(new EmptyPointsPolicyTarget())); + _assertPoints(RAW_POINTS, RAW_POINTS, RAW_POINTS); + } + + function test_extremePointsDoNotOverflow() public { + _submit(IAntseedVerification.Verdict.DIFF, 2_500, keccak256("extreme")); + uint256 rawPoints = type(uint256).max; + uint256 expectedSellerPoints = (rawPoints / 10_000) * 7_500 + ((rawPoints % 10_000) * 7_500) / 10_000; + _assertPoints(rawPoints, expectedSellerPoints, rawPoints); + } + + function test_constructorRejectsZeroAddress() public { + AntseedEmissionsGate gate = new AntseedEmissionsGate(address(0x1111), address(0x2222), 15_000, 15_000); + vm.expectRevert(AntseedVerification.InvalidAddress.selector); + new AntseedVerification(address(0), address(gate)); + } + + function _submit(IAntseedVerification.Verdict verdict, uint16 modelShareBps, bytes32 evidenceHash) private { + uint256 epoch = verification.currentEpoch(); + IAntseedVerification.VerificationResult[] memory results = new IAntseedVerification.VerificationResult[](1); + results[0] = IAntseedVerification.VerificationResult({ + agentId: AGENT_ID, + serviceHash: SERVICE_HASH, + verdict: verdict, + modelShareBps: modelShareBps + }); + vm.prank(verifier); + verification.submitVerificationBundle( + epoch, + 1_000_000, + evidenceHash, + "", + results + ); + } + + function _assertPoints(uint256 rawPoints, uint256 expectedSellerPoints, uint256 expectedBuyerPoints) private view { + (uint256 sellerPoints, uint256 buyerPoints) = verification.points(bytes32(0), buyer, seller, rawPoints); + assertEq(sellerPoints, expectedSellerPoints); + assertEq(buyerPoints, expectedBuyerPoints); + } +} diff --git a/packages/contracts/test/verification/AntseedVerifierRegistry.t.sol b/packages/contracts/test/verification/AntseedVerifierRegistry.t.sol new file mode 100644 index 000000000..5646a7ecb --- /dev/null +++ b/packages/contracts/test/verification/AntseedVerifierRegistry.t.sol @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { Vm } from "forge-std/Vm.sol"; + +import { AntseedRegistry } from "../../core/AntseedRegistry.sol"; +import { AntseedEmissionsGate } from "../../emissions/AntseedEmissionsGate.sol"; +import { IAntseedVerification } from "../../interfaces/IAntseedVerification.sol"; +import { AntseedVerification } from "../../verification/AntseedVerification.sol"; +import { MockERC8004Registry } from "../mocks/MockERC8004Registry.sol"; + +contract AntseedVerifierRegistryTest is Test { + uint256 private constant GENESIS = 1_775_728_461; + address private constant ANTS_TOKEN = 0xa87EE81b2C0Bc659307ca2D9ffdC38514DD85263; + bytes32 private constant VERIFICATION_MINTER_ID = keccak256("antseed.emissions.verification.v1"); + bytes32 private constant SERVICE_HASH = keccak256("gpt-5.6-sol"); + bytes32 private constant OTHER_SERVICE_HASH = keccak256("gpt-5.6-luna"); + + address private verifier = address(0xA11CE); + address private secondVerifier = address(0xB0B); + address private seller = address(0xCAFE); + + AntseedVerification private verification; + MockERC8004Registry private identityRegistry; + uint256 private agentId; + + function setUp() public { + vm.warp(GENESIS + 8 days); + AntseedRegistry registry = new AntseedRegistry(); + deployCodeTo("ANTSToken.sol:ANTSToken", ANTS_TOKEN); + registry.setAntsToken(ANTS_TOKEN); + registry.setTeamWallet(address(0x1111)); + registry.setProtocolReserve(address(0x2222)); + identityRegistry = new MockERC8004Registry(); + registry.setIdentityRegistry(address(identityRegistry)); + AntseedEmissionsGate gate = new AntseedEmissionsGate(address(0x1111), address(0x2222), 15_000, 15_000); + verification = new AntseedVerification(address(registry), address(gate)); + gate.setMinter(VERIFICATION_MINTER_ID, address(verification), 10_000, true); + verification.setVerifier(verifier, true); + verification.setVerifier(secondVerifier, true); + vm.prank(seller); + agentId = identityRegistry.register(); + vm.warp(GENESIS + verification.firstRewardedEpoch() * 7 days + 1); + } + + function test_submitBundleCreditsVerifierByExactUsdCost() public { + _submit(verifier, keccak256("bundle-1"), _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0), 1_000_001); + + assertEq(verification.epochCreditUsdMicros(verification.currentEpoch(), verifier), 1_000_001); + assertEq(verification.epochTotalCreditUsdMicros(verification.currentEpoch()), 1_000_001); + } + + function test_emitsOneBundleEventAndOneResultEventPerSeller() public { + IAntseedVerification.VerificationResult[] memory results = new IAntseedVerification.VerificationResult[](2); + results[0] = _result(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0); + results[1] = _result(_register(address(0xD00D)), SERVICE_HASH, IAntseedVerification.Verdict.DIFF, 2_500); + + vm.recordLogs(); + _submit(verifier, keccak256("events"), results, 2_500_000); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 bundleTopic = keccak256( + "VerificationBundleSubmitted(bytes32,address,uint256,uint64,uint64,uint32,string)" + ); + bytes32 resultTopic = keccak256("VerificationResultSubmitted(bytes32,uint256,bytes32,uint8,uint16)"); + uint256 bundleEvents; + uint256 resultEvents; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length == 0) continue; + if (logs[i].topics[0] == bundleTopic) bundleEvents++; + if (logs[i].topics[0] == resultTopic) resultEvents++; + } + assertEq(bundleEvents, 1); + assertEq(resultEvents, 2); + } + + function test_emitsIpfsEvidenceUriInBundleEvent() public { + bytes32 evidenceHash = keccak256("ipfs-evidence"); + string memory evidenceUri = "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3r3eifqeedsvt2eubqtskghpm"; + vm.recordLogs(); + _submitWithUri( + verifier, + evidenceHash, + evidenceUri, + _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0), + 1_000_000 + ); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 bundleTopic = keccak256( + "VerificationBundleSubmitted(bytes32,address,uint256,uint64,uint64,uint32,string)" + ); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length == 0 || logs[i].topics[0] != bundleTopic) continue; + (, , , string memory emittedUri) = abi.decode(logs[i].data, (uint64, uint64, uint32, string)); + assertEq(emittedUri, evidenceUri); + return; + } + fail("bundle event not emitted"); + } + + function test_rejectsInvalidAndOversizedEvidenceUris() public { + IAntseedVerification.VerificationResult[] memory results = + _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0); + + vm.prank(verifier); + vm.expectRevert(AntseedVerification.InvalidEvidenceUri.selector); + verification.submitVerificationBundle( + _currentEpoch(), 1_000_000, keccak256("https-evidence"), "https://example.invalid/evidence", results + ); + + vm.prank(verifier); + vm.expectRevert(AntseedVerification.InvalidEvidenceUri.selector); + verification.submitVerificationBundle( + _currentEpoch(), 1_000_000, keccak256("empty-cid"), "ipfs://", results + ); + + vm.prank(verifier); + vm.expectRevert(AntseedVerification.InvalidEvidenceUri.selector); + verification.submitVerificationBundle( + _currentEpoch(), + 1_000_000, + keccak256("oversized-uri"), + string.concat("ipfs://", string(new bytes(194))), + results + ); + } + + function test_awardsOnlyRemainingCreditsAtEpochCap() public { + verification.setMaxCreditUsdMicrosPerVerifierPerEpoch(3_000_000); + _submit(verifier, keccak256("bundle-a"), _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0), 2_000_000); + _submit(verifier, keccak256("bundle-b"), _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.DIFF, 2_500), 2_000_000); + + assertEq(verification.agentPointsPenaltyBps(agentId), 2_500); + assertEq(verification.epochCreditUsdMicros(verification.currentEpoch(), verifier), 3_000_000); + assertEq(verification.epochTotalCreditUsdMicros(verification.currentEpoch()), 3_000_000); + } + + function test_epochCreditCapIsOwnerConfigurableAndMustBeNonzero() public { + verification.setMaxCreditUsdMicrosPerVerifierPerEpoch(7_000_000); + assertEq(verification.maxCreditUsdMicrosPerVerifierPerEpoch(), 7_000_000); + + vm.expectRevert(AntseedVerification.InvalidValue.selector); + verification.setMaxCreditUsdMicrosPerVerifierPerEpoch(0); + } + + function test_onlyOwnerCanConfigureAndOnlyApprovedVerifiersCanSubmit() public { + address unauthorized = address(0xBAD); + vm.startPrank(unauthorized); + vm.expectRevert(); + verification.setVerifier(unauthorized, true); + vm.expectRevert(); + verification.setMaxCreditUsdMicrosPerVerifierPerEpoch(1_000_000); + vm.expectRevert(AntseedVerification.NotApprovedVerifier.selector); + verification.submitVerificationBundle( + _currentEpoch(), + 1_000_000, + keccak256("unauthorized-evidence"), + "", + _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0) + ); + vm.stopPrank(); + } + + function test_allFinalVerdictsCanEarnCredits() public { + IAntseedVerification.VerificationResult[] memory results = new IAntseedVerification.VerificationResult[](3); + results[0] = _result(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0); + results[1] = _result(_register(address(0xD00D)), SERVICE_HASH, IAntseedVerification.Verdict.DIFF, 2_500); + results[2] = _result(_register(address(0xF00D)), SERVICE_HASH, IAntseedVerification.Verdict.UNDETERMINED, 0); + _submit(verifier, keccak256("all-verdicts"), results, 3_000_000); + assertEq(verification.epochCreditUsdMicros(verification.currentEpoch(), verifier), 3_000_000); + } + + function test_zeroCostAndZeroShareDiffAreAcceptedAndClearPenalty() public { + _submit(verifier, keccak256("penalty"), _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.DIFF, 2_500), 1_000_000); + _submit(verifier, keccak256("zero"), _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.DIFF, 0), 0); + + assertEq(verification.epochCreditUsdMicros(verification.currentEpoch(), verifier), 1_000_000); + assertEq(verification.agentPointsPenaltyBps(agentId), 0); + } + + function test_rejectsBundleAfterExpectedEpochChanges() public { + uint256 expectedEpoch = _currentEpoch(); + vm.warp(block.timestamp + 7 days); + vm.prank(verifier); + vm.expectRevert(AntseedVerification.EpochChanged.selector); + verification.submitVerificationBundle( + expectedEpoch, + 1_000_000, + keccak256("stale-evidence"), + "", + _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0) + ); + } + + function test_undeterminedLeavesPenaltyUnchanged() public { + _submit(verifier, keccak256("diff"), _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.DIFF, 2_500), 1_000_000); + _submit(verifier, keccak256("undetermined"), _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.UNDETERMINED, 0), 1_000_000); + + assertEq(verification.agentPointsPenaltyBps(agentId), 2_500); + } + + function test_latestConclusiveResultAcrossBundlesControlsAgentPenalty() public { + _submit(verifier, keccak256("diff-one"), _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.DIFF, 2_000), 1_000_000); + _submit(secondVerifier, keccak256("diff-two"), _oneResult(agentId, OTHER_SERVICE_HASH, IAntseedVerification.Verdict.DIFF, 3_000), 1_000_000); + assertEq(verification.agentPointsPenaltyBps(agentId), 3_000); + } + + function test_rejectsDuplicateEvidenceUnknownSelfDuplicateResultAndInvalidInputs() public { + bytes32 evidenceHash = keccak256("validation"); + _submit(verifier, evidenceHash, _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0), 1_000_000); + assertTrue(verification.isVerificationSubmitted(evidenceHash)); + + vm.prank(verifier); + vm.expectRevert(AntseedVerification.VerificationAlreadySubmitted.selector); + verification.submitVerificationBundle( + _currentEpoch(), 1_000_000, evidenceHash, + "", + _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0) + ); + + vm.prank(verifier); + vm.expectRevert(AntseedVerification.UnknownAgent.selector); + verification.submitVerificationBundle( + _currentEpoch(), 1_000_000, keccak256("unknown-evidence"), + "", + _oneResult(999, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0) + ); + + verification.setVerifier(seller, true); + vm.prank(seller); + vm.expectRevert(AntseedVerification.SelfAudit.selector); + verification.submitVerificationBundle( + _currentEpoch(), 1_000_000, keccak256("self-evidence"), + "", + _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 0) + ); + + vm.prank(verifier); + vm.expectRevert(AntseedVerification.InvalidModelShare.selector); + verification.submitVerificationBundle( + _currentEpoch(), 1_000_000, keccak256("bad-share-evidence"), + "", + _oneResult(agentId, SERVICE_HASH, IAntseedVerification.Verdict.SAME, 1) + ); + } + + function _submit( + address caller, + bytes32 evidenceHash, + IAntseedVerification.VerificationResult[] memory results, + uint64 costUsdMicros + ) private { + _submitWithUri(caller, evidenceHash, "", results, costUsdMicros); + } + + function _submitWithUri( + address caller, + bytes32 evidenceHash, + string memory evidenceUri, + IAntseedVerification.VerificationResult[] memory results, + uint64 costUsdMicros + ) private { + vm.prank(caller); + verification.submitVerificationBundle( + _currentEpoch(), + costUsdMicros, + evidenceHash, + evidenceUri, + results + ); + } + + function _oneResult( + uint256 targetAgentId, + bytes32 serviceHash, + IAntseedVerification.Verdict verdict, + uint16 modelShareBps + ) private pure returns (IAntseedVerification.VerificationResult[] memory results) { + results = new IAntseedVerification.VerificationResult[](1); + results[0] = _result(targetAgentId, serviceHash, verdict, modelShareBps); + } + + function _result( + uint256 targetAgentId, + bytes32 serviceHash, + IAntseedVerification.Verdict verdict, + uint16 modelShareBps + ) private pure returns (IAntseedVerification.VerificationResult memory) { + return IAntseedVerification.VerificationResult({ + agentId: targetAgentId, + serviceHash: serviceHash, + verdict: verdict, + modelShareBps: modelShareBps + }); + } + + function _register(address owner) private returns (uint256 registeredAgentId) { + vm.prank(owner); + registeredAgentId = identityRegistry.register(); + } + + function _currentEpoch() private view returns (uint256) { + return (block.timestamp - GENESIS) / 7 days; + } +} diff --git a/packages/contracts/test/verification/AntseedVerifierRewards.t.sol b/packages/contracts/test/verification/AntseedVerifierRewards.t.sol new file mode 100644 index 000000000..4f6ed862e --- /dev/null +++ b/packages/contracts/test/verification/AntseedVerifierRewards.t.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; + +import { ANTSToken } from "../../core/ANTSToken.sol"; +import { AntseedRegistry } from "../../core/AntseedRegistry.sol"; +import { AntseedEmissionsGate } from "../../emissions/AntseedEmissionsGate.sol"; +import { IAntseedVerification } from "../../interfaces/IAntseedVerification.sol"; +import { AntseedVerification } from "../../verification/AntseedVerification.sol"; +import { MockERC8004Registry } from "../mocks/MockERC8004Registry.sol"; + +contract AntseedVerifierRewardsTest is Test { + uint256 private constant GENESIS = 1_775_728_461; + uint256 private constant EPOCH_DURATION = 7 days; + address private constant ANTS_TOKEN = 0xa87EE81b2C0Bc659307ca2D9ffdC38514DD85263; + bytes32 private constant VERIFICATION_MINTER_ID = keccak256("antseed.emissions.verification.v1"); + bytes32 private constant SERVICE_HASH = keccak256("gpt-5.6-sol"); + + address private verifierA = address(0xA11CE); + address private verifierB = address(0xB0B); + ANTSToken private token; + AntseedEmissionsGate private gate; + AntseedVerification private verification; + MockERC8004Registry private identity; + + function setUp() public { + vm.warp(GENESIS + 8 days); + AntseedRegistry core = new AntseedRegistry(); + deployCodeTo("ANTSToken.sol:ANTSToken", ANTS_TOKEN); + token = ANTSToken(ANTS_TOKEN); + core.setAntsToken(ANTS_TOKEN); + core.setTeamWallet(address(0x1111)); + core.setProtocolReserve(address(0x2222)); + identity = new MockERC8004Registry(); + core.setIdentityRegistry(address(identity)); + gate = new AntseedEmissionsGate(address(0x1111), address(0x2222), 15_000, 15_000); + verification = new AntseedVerification(address(core), address(gate)); + gate.setMinter(VERIFICATION_MINTER_ID, address(verification), 10_000, true); + token.setRegistry(address(gate)); + gate.fundLegacyEscrow(address(0xE5C0)); + verification.setVerifier(verifierA, true); + verification.setVerifier(verifierB, true); + } + + function test_claimsProRataVerifierOnlyPool() public { + uint256 rewardedEpoch = verification.firstRewardedEpoch(); + _warpToEpoch(rewardedEpoch); + _submit(verifierA, _register(address(0xCAFE)), keccak256("a"), 1_000_000); + _submit(verifierB, _register(address(0xD00D)), keccak256("b"), 2_000_000); + + uint256 budget = verification.verifierEpochBudget(rewardedEpoch); + _warpToEpoch(rewardedEpoch + 1); + assertEq(verification.pendingVerifierReward(rewardedEpoch, verifierA), budget / 3); + assertEq(verification.pendingVerifierReward(rewardedEpoch, verifierB), (budget * 2) / 3); + + vm.prank(verifierA); + verification.claimVerifierReward(rewardedEpoch); + vm.prank(verifierB); + verification.claimVerifierReward(rewardedEpoch); + assertEq(token.balanceOf(verifierA), budget / 3); + assertEq(token.balanceOf(verifierB), (budget * 2) / 3); + + vm.prank(verifierA); + vm.expectRevert(AntseedVerification.NothingToClaim.selector); + verification.claimVerifierReward(rewardedEpoch); + assertEq(verification.epochCreditUsdMicros(rewardedEpoch, verifierA), 0); + } + + function test_preRewardedEpochAppliesResultsWithoutRecordingDeadCredits() public { + uint256 agentId = _register(address(0xCAFE)); + bytes32 evidenceHash = keccak256("pre-rewarded"); + IAntseedVerification.VerificationResult[] memory results = new IAntseedVerification.VerificationResult[](1); + results[0] = IAntseedVerification.VerificationResult({ + agentId: agentId, + serviceHash: SERVICE_HASH, + verdict: IAntseedVerification.Verdict.DIFF, + modelShareBps: 2_500 + }); + vm.prank(verifierA); + verification.submitVerificationBundle( + _currentEpoch(), 1_000_000, evidenceHash, "", results + ); + + assertTrue(verification.isVerificationSubmitted(evidenceHash)); + assertEq(verification.agentPointsPenaltyBps(agentId), 2_500); + assertEq(verification.epochCreditUsdMicros(_currentEpoch(), verifierA), 0); + assertEq(verification.epochTotalCreditUsdMicros(_currentEpoch()), 0); + } + + function test_missingCurrentEpochBudgetAppliesResultsWithoutRecordingDeadCredits() public { + uint256 rewardedEpoch = verification.firstRewardedEpoch(); + _warpToEpoch(rewardedEpoch); + gate.setMinterController(VERIFICATION_MINTER_ID, address(0xD00D)); + + uint256 agentId = _register(address(0xCAFE)); + _submit(verifierA, agentId, keccak256("no-live-budget"), 1_000_000); + + assertTrue(verification.isVerificationSubmitted(keccak256("no-live-budget"))); + assertEq(verification.epochCreditUsdMicros(rewardedEpoch, verifierA), 0); + assertEq(verification.epochTotalCreditUsdMicros(rewardedEpoch), 0); + } + + function test_zeroBudgetClaimRevertsWithoutErasingCredits() public { + uint256 rewardedEpoch = verification.firstRewardedEpoch(); + _warpToEpoch(rewardedEpoch); + _submit(verifierA, _register(address(0xCAFE)), keccak256("rotation"), 1_000_000); + _warpToEpoch(rewardedEpoch + 1); + + gate.setMinterController(VERIFICATION_MINTER_ID, address(0xD00D)); + vm.prank(verifierA); + vm.expectRevert(AntseedVerification.NothingToClaim.selector); + verification.claimVerifierReward(rewardedEpoch); + assertEq(verification.epochCreditUsdMicros(rewardedEpoch, verifierA), 1_000_000); + + gate.setMinterController(VERIFICATION_MINTER_ID, address(verification)); + vm.prank(verifierA); + verification.claimVerifierReward(rewardedEpoch); + assertGt(token.balanceOf(verifierA), 0); + } + + function test_firstClaimFreezesBudgetAndCreditDenominator() public { + uint256 rewardedEpoch = verification.firstRewardedEpoch(); + _warpToEpoch(rewardedEpoch); + _submit(verifierA, _register(address(0xCAFE)), keccak256("freeze-a"), 1_000_000); + _submit(verifierB, _register(address(0xD00D)), keccak256("freeze-b"), 1_000_000); + + uint256 budget = verification.verifierEpochBudget(rewardedEpoch); + _warpToEpoch(rewardedEpoch + 1); + vm.prank(verifierA); + verification.claimVerifierReward(rewardedEpoch); + + gate.setMinterController(VERIFICATION_MINTER_ID, address(0xF00D)); + assertEq(gate.controllerEpochBudget(address(verification), rewardedEpoch), 0); + assertEq(verification.verifierEpochBudget(rewardedEpoch), budget); + assertEq(verification.verifierEpochTotalCreditUsdMicros(rewardedEpoch), 2_000_000); + assertEq(verification.pendingVerifierReward(rewardedEpoch, verifierB), budget / 2); + } + + function test_zeroCreditEpochCanSettleRemainder() public { + uint256 rewardedEpoch = verification.firstRewardedEpoch(); + _warpToEpoch(rewardedEpoch + 1); + (uint256 burned, uint256 reserved) = verification.settleEpochRemainder(rewardedEpoch); + assertGt(burned + reserved, 0); + assertTrue(verification.epochRemainderSettled(rewardedEpoch)); + } + + function test_outstandingRewardRemainsClaimableInLaterEpoch() public { + uint256 rewardedEpoch = verification.firstRewardedEpoch(); + _warpToEpoch(rewardedEpoch); + _submit(verifierA, _register(address(0xCAFE)), keccak256("delayed"), 1_000_000); + + uint256 budget = verification.verifierEpochBudget(rewardedEpoch); + _warpToEpoch(rewardedEpoch + 10); + + assertEq(verification.pendingVerifierReward(rewardedEpoch, verifierA), budget); + vm.prank(verifierA); + verification.claimVerifierReward(rewardedEpoch); + assertEq(token.balanceOf(verifierA), budget); + assertEq(verification.epochCreditUsdMicros(rewardedEpoch, verifierA), 0); + assertEq(verification.pendingVerifierReward(rewardedEpoch, verifierA), 0); + } + + function testFuzz_claimedRewardsNeverExceedFrozenBudget(uint64 creditA, uint64 creditB) public { + creditA = uint64(bound(creditA, 1, verification.maxCreditUsdMicrosPerVerifierPerEpoch())); + creditB = uint64(bound(creditB, 1, verification.maxCreditUsdMicrosPerVerifierPerEpoch())); + + uint256 rewardedEpoch = verification.firstRewardedEpoch(); + _warpToEpoch(rewardedEpoch); + _submit(verifierA, _register(address(0xCAFE)), keccak256(abi.encode("fuzz-a", creditA)), creditA); + _submit(verifierB, _register(address(0xD00D)), keccak256(abi.encode("fuzz-b", creditB)), creditB); + _warpToEpoch(rewardedEpoch + 1); + + uint256 budget = verification.verifierEpochBudget(rewardedEpoch); + uint256 pendingA = verification.pendingVerifierReward(rewardedEpoch, verifierA); + uint256 pendingB = verification.pendingVerifierReward(rewardedEpoch, verifierB); + vm.prank(verifierA); + verification.claimVerifierReward(rewardedEpoch); + vm.prank(verifierB); + verification.claimVerifierReward(rewardedEpoch); + + assertEq(token.balanceOf(verifierA), pendingA); + assertEq(token.balanceOf(verifierB), pendingB); + assertLe(pendingA + pendingB, budget); + } + + function _register(address seller) private returns (uint256 agentId) { + vm.prank(seller); + agentId = identity.register(); + } + + function _submit(address verifier, uint256 agentId, bytes32 evidenceHash, uint64 creditUsdMicros) private { + IAntseedVerification.VerificationResult[] memory results = new IAntseedVerification.VerificationResult[](1); + results[0] = IAntseedVerification.VerificationResult({ + agentId: agentId, + serviceHash: SERVICE_HASH, + verdict: IAntseedVerification.Verdict.SAME, + modelShareBps: 0 + }); + vm.prank(verifier); + verification.submitVerificationBundle( + _currentEpoch(), + creditUsdMicros, + evidenceHash, + "", + results + ); + } + + function _warpToEpoch(uint256 epoch) private { + vm.warp(gate.GENESIS() + epoch * gate.EPOCH_DURATION() + 1); + } + + function _currentEpoch() private view returns (uint256) { + return (block.timestamp - GENESIS) / EPOCH_DURATION; + } +} diff --git a/packages/contracts/verification/AntseedVerification.sol b/packages/contracts/verification/AntseedVerification.sol new file mode 100644 index 000000000..e80f265a1 --- /dev/null +++ b/packages/contracts/verification/AntseedVerification.sol @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +import { IAntseedEmissionsGate } from "../interfaces/IAntseedEmissionsGate.sol"; +import { IAntseedRegistry } from "../interfaces/IAntseedRegistry.sol"; +import { IAntseedStaking } from "../interfaces/IAntseedStaking.sol"; +import { IAntseedVerification } from "../interfaces/IAntseedVerification.sol"; +import { IERC8004Registry } from "../interfaces/IERC8004Registry.sol"; + +contract AntseedVerification is IAntseedVerification, Ownable2Step, ReentrancyGuard { + uint256 public constant BPS_DENOMINATOR = 10_000; + uint256 public constant MAX_EVIDENCE_URI_BYTES = 200; + IAntseedRegistry public immutable override registry; + IAntseedEmissionsGate public immutable override emissionsGate; + uint256 public immutable override firstRewardedEpoch; + + mapping(address verifier => bool approved) public override approvedVerifiers; + + /// @notice Credit weights use six-decimal USD micros: 1 credit = $1 = 1_000_000 units. + /// @dev The default cap is 100 credits. Fractional credits remain exact, so $1.20 is 1_200_000 units. + uint64 public override maxCreditUsdMicrosPerVerifierPerEpoch = 100_000_000; + + mapping(bytes32 evidenceHash => bool submitted) private _submittedVerifications; + mapping(uint256 agentId => uint16 penaltyBps) private _agentPointsPenaltyBps; + + mapping(uint256 epoch => mapping(address verifier => uint256 creditUsdMicros)) public override epochCreditUsdMicros; + mapping(uint256 epoch => uint256 creditUsdMicros) public override epochTotalCreditUsdMicros; + + mapping(uint256 epoch => uint256 budgetPlusOne) private _frozenEpochBudgets; + mapping(uint256 epoch => uint256 totalCreditUsdMicrosPlusOne) private _frozenEpochTotalCreditUsdMicros; + mapping(uint256 epoch => bool settled) public override epochRemainderSettled; + + event VerifierApprovalSet(address indexed verifier, bool approved); + event MaxCreditUsdMicrosPerVerifierPerEpochSet(uint64 maximum); + event VerificationBundleSubmitted( + bytes32 indexed evidenceHash, + address indexed verifier, + uint256 indexed epoch, + uint64 totalAuditCostUsdMicros, + uint64 awardedCreditUsdMicros, + uint32 resultCount, + string evidenceUri + ); + event VerificationResultSubmitted( + bytes32 indexed evidenceHash, + uint256 indexed agentId, + bytes32 indexed serviceHash, + Verdict verdict, + uint16 modelShareBps + ); + event AgentPointsPenaltySet(uint256 indexed agentId, uint16 penaltyBps); + event VerifierRewardClaimed(uint256 indexed epoch, address indexed verifier, uint256 amount); + event VerifierEpochRemainderSettled(uint256 indexed epoch, uint256 amount); + + error InvalidAddress(); + error InvalidValue(); + error NotApprovedVerifier(); + error InvalidVerdict(); + error InvalidModelShare(); + error EpochChanged(); + error VerificationAlreadySubmitted(); + error InvalidEvidenceUri(); + error UnknownAgent(); + error SelfAudit(); + error PreEffectiveEpoch(); + error EpochNotFinalized(); + error AlreadyClaimed(); + error NothingToClaim(); + error NothingToSettle(); + + modifier onlyApprovedVerifier() { + if (!approvedVerifiers[msg.sender]) revert NotApprovedVerifier(); + _; + } + + constructor(address registry_, address emissionsGate_) Ownable(msg.sender) { + if (registry_ == address(0) || emissionsGate_ == address(0)) revert InvalidAddress(); + if (registry_.code.length == 0 || emissionsGate_.code.length == 0) revert InvalidAddress(); + registry = IAntseedRegistry(registry_); + emissionsGate = IAntseedEmissionsGate(emissionsGate_); + firstRewardedEpoch = Math.max(emissionsGate.effectiveEpoch(), emissionsGate.currentEpoch() + 1); + } + + function setVerifier(address verifier, bool approved) external override onlyOwner { + if (verifier == address(0)) revert InvalidAddress(); + approvedVerifiers[verifier] = approved; + emit VerifierApprovalSet(verifier, approved); + } + + function setMaxCreditUsdMicrosPerVerifierPerEpoch(uint64 maximum) external override onlyOwner { + if (maximum == 0) revert InvalidValue(); + maxCreditUsdMicrosPerVerifierPerEpoch = maximum; + emit MaxCreditUsdMicrosPerVerifierPerEpochSet(maximum); + } + + /// @notice Submits the audit for one model across multiple seller peers. + function submitVerificationBundle( + uint256 expectedEpoch, + uint64 totalAuditCostUsdMicros, + bytes32 evidenceHash, + string calldata evidenceUri, + VerificationResult[] calldata results + ) external override onlyApprovedVerifier nonReentrant { + if (evidenceHash == bytes32(0)) revert InvalidValue(); + _validateEvidenceUri(evidenceUri); + if (_submittedVerifications[evidenceHash]) revert VerificationAlreadySubmitted(); + uint256 epoch = currentEpoch(); + if (epoch != expectedEpoch) revert EpochChanged(); + + for (uint256 i = 0; i < results.length; i++) { + VerificationResult calldata result = results[i]; + _validateResult(result); + if (_resolveAgentOwner(result.agentId) == msg.sender) revert SelfAudit(); + } + + _submittedVerifications[evidenceHash] = true; + + uint64 awardedCreditUsdMicros; + if (epoch >= firstRewardedEpoch && emissionsGate.controllerEpochBudget(address(this), epoch) != 0) { + uint256 currentCreditUsdMicros = epochCreditUsdMicros[epoch][msg.sender]; + uint256 remainingCreditUsdMicros = currentCreditUsdMicros < maxCreditUsdMicrosPerVerifierPerEpoch + ? uint256(maxCreditUsdMicrosPerVerifierPerEpoch) - currentCreditUsdMicros + : 0; + awardedCreditUsdMicros = totalAuditCostUsdMicros < remainingCreditUsdMicros + ? totalAuditCostUsdMicros + : uint64(remainingCreditUsdMicros); + if (awardedCreditUsdMicros != 0) { + epochCreditUsdMicros[epoch][msg.sender] = currentCreditUsdMicros + awardedCreditUsdMicros; + epochTotalCreditUsdMicros[epoch] += awardedCreditUsdMicros; + } + } + + emit VerificationBundleSubmitted( + evidenceHash, + msg.sender, + epoch, + totalAuditCostUsdMicros, + awardedCreditUsdMicros, + uint32(results.length), + evidenceUri + ); + for (uint256 i = 0; i < results.length; i++) { + VerificationResult calldata result = results[i]; + _applyAttestationPenalty(result.agentId, result.verdict, result.modelShareBps); + emit VerificationResultSubmitted( + evidenceHash, result.agentId, result.serviceHash, result.verdict, result.modelShareBps + ); + } + } + + function _validateEvidenceUri(string calldata evidenceUri) private pure { + bytes memory uri = bytes(evidenceUri); + if (uri.length == 0) return; + if ( + uri.length <= 7 || uri.length > MAX_EVIDENCE_URI_BYTES || uri[0] != bytes1("i") || uri[1] != bytes1("p") + || uri[2] != bytes1("f") || uri[3] != bytes1("s") || uri[4] != bytes1(":") || uri[5] != bytes1("/") + || uri[6] != bytes1("/") + ) revert InvalidEvidenceUri(); + } + + function isVerificationSubmitted(bytes32 evidenceHash) external view override returns (bool) { + return _submittedVerifications[evidenceHash]; + } + + function currentEpoch() public view override returns (uint256) { + return emissionsGate.currentEpoch(); + } + + function agentPointsPenaltyBps(uint256 agentId) external view override returns (uint16) { + return _agentPointsPenaltyBps[agentId]; + } + + function points(bytes32, address, address seller, uint256 rawPoints) + external + view + override + returns (uint256 sellerPoints, uint256 buyerPoints) + { + buyerPoints = rawPoints; + sellerPoints = rawPoints; + + uint256 agentId = _resolveSellerAgentId(seller); + if (agentId == 0) return (sellerPoints, buyerPoints); + + uint16 penaltyBps = _agentPointsPenaltyBps[agentId]; + if (penaltyBps == 0) return (sellerPoints, buyerPoints); + if (penaltyBps >= BPS_DENOMINATOR) return (0, buyerPoints); + sellerPoints = _applyKeepBps(rawPoints, BPS_DENOMINATOR - penaltyBps); + } + + function claimVerifierReward(uint256 epoch) external override nonReentrant { + if (epoch < firstRewardedEpoch) revert PreEffectiveEpoch(); + if (epoch >= currentEpoch()) revert EpochNotFinalized(); + uint256 creditUsdMicros = epochCreditUsdMicros[epoch][msg.sender]; + if (creditUsdMicros == 0) revert NothingToClaim(); + (uint256 budget, uint256 totalCreditUsdMicros) = _freezeEpochRewardState(epoch); + if (budget == 0 || totalCreditUsdMicros == 0) revert NothingToClaim(); + + uint256 amount = Math.mulDiv(budget, creditUsdMicros, totalCreditUsdMicros); + epochCreditUsdMicros[epoch][msg.sender] = 0; + if (amount != 0) emissionsGate.claim(epoch, msg.sender, amount); + emit VerifierRewardClaimed(epoch, msg.sender, amount); + } + + function settleEpochRemainder(uint256 epoch) + external + override + nonReentrant + returns (uint256 burnedAmount, uint256 reserveAmount) + { + if (epoch < firstRewardedEpoch) revert PreEffectiveEpoch(); + if (epoch >= currentEpoch()) revert EpochNotFinalized(); + if (epochRemainderSettled[epoch]) revert AlreadyClaimed(); + (uint256 budget, uint256 totalCreditUsdMicros) = _freezeEpochRewardState(epoch); + if (totalCreditUsdMicros != 0 || budget == 0) revert NothingToSettle(); + + epochRemainderSettled[epoch] = true; + (burnedAmount, reserveAmount) = emissionsGate.claimRemainder(epoch, emissionsGate.emissionsReserve(), budget); + emit VerifierEpochRemainderSettled(epoch, budget); + } + + function pendingVerifierReward(uint256 epoch, address verifier) external view override returns (uint256) { + if (epoch < firstRewardedEpoch || epoch >= currentEpoch()) return 0; + uint256 creditUsdMicros = epochCreditUsdMicros[epoch][verifier]; + if (creditUsdMicros == 0) return 0; + uint256 totalCreditUsdMicros = verifierEpochTotalCreditUsdMicros(epoch); + if (totalCreditUsdMicros == 0) return 0; + return Math.mulDiv(verifierEpochBudget(epoch), creditUsdMicros, totalCreditUsdMicros); + } + + function verifierEpochBudget(uint256 epoch) public view override returns (uint256) { + uint256 frozenBudget = _frozenEpochBudgets[epoch]; + if (frozenBudget != 0) return frozenBudget - 1; + return emissionsGate.controllerEpochBudget(address(this), epoch); + } + + function verifierEpochTotalCreditUsdMicros(uint256 epoch) public view override returns (uint256) { + uint256 frozenTotalCreditUsdMicros = _frozenEpochTotalCreditUsdMicros[epoch]; + if (frozenTotalCreditUsdMicros != 0) return frozenTotalCreditUsdMicros - 1; + return epochTotalCreditUsdMicros[epoch]; + } + + function _freezeEpochRewardState(uint256 epoch) private returns (uint256 budget, uint256 totalCreditUsdMicros) { + uint256 frozenBudget = _frozenEpochBudgets[epoch]; + if (frozenBudget != 0) { + return (frozenBudget - 1, _frozenEpochTotalCreditUsdMicros[epoch] - 1); + } + + budget = emissionsGate.controllerEpochBudget(address(this), epoch); + totalCreditUsdMicros = epochTotalCreditUsdMicros[epoch]; + _frozenEpochBudgets[epoch] = budget + 1; + _frozenEpochTotalCreditUsdMicros[epoch] = totalCreditUsdMicros + 1; + } + + function _resolveAgentOwner(uint256 agentId) private view returns (address) { + address identityRegistry = registry.identityRegistry(); + if (identityRegistry == address(0) || identityRegistry.code.length == 0) revert UnknownAgent(); + try IERC8004Registry(identityRegistry).ownerOf(agentId) returns (address owner) { + if (owner == address(0)) revert UnknownAgent(); + return owner; + } catch { + revert UnknownAgent(); + } + } + + function _resolveSellerAgentId(address seller) private view returns (uint256) { + (bool registryOk, uint256 stakingValue) = + _readUint256(address(registry), abi.encodeCall(IAntseedRegistry.staking, ())); + if (!registryOk) return 0; + + address staking = address(uint160(stakingValue)); + if (staking == address(0)) return 0; + + (bool stakingOk, uint256 agentId) = _readUint256(staking, abi.encodeCall(IAntseedStaking.getAgentId, (seller))); + return stakingOk ? agentId : 0; + } + + function _readUint256(address target, bytes memory callData) private view returns (bool ok, uint256 value) { + if (target.code.length == 0) return (false, 0); + + bytes memory data; + (ok, data) = target.staticcall(callData); + if (!ok || data.length < 32) return (false, 0); + value = abi.decode(data, (uint256)); + } + + function _applyAttestationPenalty(uint256 agentId, Verdict verdict, uint16 modelShareBps) private { + if (verdict == Verdict.UNDETERMINED) return; + uint16 nextPenalty = verdict == Verdict.DIFF ? modelShareBps : 0; + if (_agentPointsPenaltyBps[agentId] == nextPenalty) return; + _agentPointsPenaltyBps[agentId] = nextPenalty; + emit AgentPointsPenaltySet(agentId, nextPenalty); + } + + function _validateResult(VerificationResult calldata result) private pure { + if (result.agentId == 0 || result.serviceHash == bytes32(0)) revert InvalidValue(); + if (result.verdict == Verdict.UNKNOWN || uint8(result.verdict) > uint8(Verdict.UNDETERMINED)) { + revert InvalidVerdict(); + } + if (result.modelShareBps > BPS_DENOMINATOR) revert InvalidModelShare(); + if (result.verdict != Verdict.DIFF && result.modelShareBps != 0) revert InvalidModelShare(); + } + + function _applyKeepBps(uint256 amount, uint256 keepBps) private pure returns (uint256) { + return Math.mulDiv(amount, keepBps, BPS_DENOMINATOR); + } +} From a246b3a80e0e0d7eabc6f179d2060eb7eb37025e Mon Sep 17 00:00:00 2001 From: alexanderludwig Date: Thu, 27 Aug 2026 18:44:34 +0200 Subject: [PATCH 02/17] feat(fingerprints): import transparent audit probes --- packages/fingerprints/package.json | 21 + packages/fingerprints/src/canonical-json.ts | 110 +++++ packages/fingerprints/src/index.ts | 10 + packages/fingerprints/src/types.ts | 264 ++++++++++++ .../fingerprints/src/verifiers/kbf/index.ts | 143 +++++++ .../fingerprints/src/verifiers/kbf/parser.ts | 58 +++ .../fingerprints/src/verifiers/kbf/prompts.ts | 67 +++ .../src/verifiers/kbf/reference.ts | 390 ++++++++++++++++++ .../fingerprints/src/verifiers/kbf/scoring.ts | 41 ++ .../fingerprints/src/verifiers/kbf/stats.ts | 165 ++++++++ .../fingerprints/src/verifiers/kbf/verdict.ts | 137 ++++++ .../fingerprints/tests/canonical-json.test.ts | 149 +++++++ .../fingerprints/tests/kbf-verifier.test.ts | 206 +++++++++ packages/fingerprints/tests/parser.test.ts | 66 +++ packages/fingerprints/tests/reference.test.ts | 287 +++++++++++++ packages/fingerprints/tests/scoring.test.ts | 82 ++++ packages/fingerprints/tests/stats.test.ts | 169 ++++++++ packages/fingerprints/tests/verdict.test.ts | 134 ++++++ packages/fingerprints/tsconfig.json | 9 + 19 files changed, 2508 insertions(+) create mode 100644 packages/fingerprints/package.json create mode 100644 packages/fingerprints/src/canonical-json.ts create mode 100644 packages/fingerprints/src/index.ts create mode 100644 packages/fingerprints/src/types.ts create mode 100644 packages/fingerprints/src/verifiers/kbf/index.ts create mode 100644 packages/fingerprints/src/verifiers/kbf/parser.ts create mode 100644 packages/fingerprints/src/verifiers/kbf/prompts.ts create mode 100644 packages/fingerprints/src/verifiers/kbf/reference.ts create mode 100644 packages/fingerprints/src/verifiers/kbf/scoring.ts create mode 100644 packages/fingerprints/src/verifiers/kbf/stats.ts create mode 100644 packages/fingerprints/src/verifiers/kbf/verdict.ts create mode 100644 packages/fingerprints/tests/canonical-json.test.ts create mode 100644 packages/fingerprints/tests/kbf-verifier.test.ts create mode 100644 packages/fingerprints/tests/parser.test.ts create mode 100644 packages/fingerprints/tests/reference.test.ts create mode 100644 packages/fingerprints/tests/scoring.test.ts create mode 100644 packages/fingerprints/tests/stats.test.ts create mode 100644 packages/fingerprints/tests/verdict.test.ts create mode 100644 packages/fingerprints/tsconfig.json diff --git a/packages/fingerprints/package.json b/packages/fingerprints/package.json new file mode 100644 index 000000000..ff0b2c95b --- /dev/null +++ b/packages/fingerprints/package.json @@ -0,0 +1,21 @@ +{ + "name": "@antseed/fingerprints", + "version": "0.1.0", + "description": "Pure verifier math for AntSeed model verification: canonical JSON hashing, KBF fingerprint verifier, cohort consensus verification, probe bank, and audit/evidence schemas", + "type": "module", + "files": [ + "dist" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "prebuild": "node ../../scripts/remove-paths.mjs dist", + "build": "tsc", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } +} diff --git a/packages/fingerprints/src/canonical-json.ts b/packages/fingerprints/src/canonical-json.ts new file mode 100644 index 000000000..a864aa04e --- /dev/null +++ b/packages/fingerprints/src/canonical-json.ts @@ -0,0 +1,110 @@ +/** + * Deterministic canonical JSON serialization and content hashing, following + * the JSON Canonicalization Scheme (JCS, RFC 8785): + * - UTF-8 JSON with no insignificant whitespace; + * - object keys sorted by UTF-16 code units (RFC 8785 §3.2.3 — JS default + * string comparison); + * - numbers serialized via the ECMAScript Number-to-string algorithm + * (RFC 8785 §3.2.2.3 — what `JSON.stringify` emits); + * - strings escaped per `JSON.stringify` (RFC 8785 §3.2.2.2). + * + * Deviations, both stricter than JCS (they reject inputs JCS cannot round-trip + * rather than coercing them): + * - non-finite numbers (NaN / Infinity / -Infinity) throw instead of + * serializing to `null`; + * - undefined / function / bigint / symbol values throw, except undefined + * object properties which are skipped exactly like JSON.stringify. + * `toJSON` is honored as input conditioning (e.g. Date), like JSON.stringify. + * + * Also required by docs/protocol/spec/07-model-verification.md + * ("Private Buyer References"). + */ + +import { createHash } from 'node:crypto'; + +/** Serialize a value to canonical JSON. Deterministic across platforms. */ +export function canonicalJsonStringify(value: unknown): string { + return serialize(value, '$'); +} + +function serialize(value: unknown, path: string): string { + // Honor toJSON like JSON.stringify does (e.g. Date). + if ( + value !== null && + (typeof value === 'object' || typeof value === 'bigint') && + typeof (value as { toJSON?: unknown }).toJSON === 'function' + ) { + value = (value as { toJSON: () => unknown }).toJSON(); + } + + if (value === null) { + return 'null'; + } + switch (typeof value) { + case 'boolean': + return value ? 'true' : 'false'; + case 'number': + if (!Number.isFinite(value)) { + throw new Error(`canonicalJsonStringify: non-finite number at ${path}`); + } + return JSON.stringify(value); + case 'string': + return JSON.stringify(value); + case 'undefined': + case 'function': + case 'bigint': + case 'symbol': + throw new Error(`canonicalJsonStringify: unsupported ${typeof value} at ${path}`); + default: + break; + } + + if (Array.isArray(value)) { + // Index loop, NOT Array.prototype.map: map skips holes in sparse arrays, + // which would emit invalid JSON like "[1,,2]". Indexing a hole reads + // undefined, which hits the unsupported-value throw path above — matching + // the module contract (undefined is only skippable as an object property). + const parts: string[] = []; + for (let i = 0; i < value.length; i++) { + parts.push(serialize(value[i], `${path}[${i}]`)); + } + return `[${parts.join(',')}]`; + } + + const obj = value as Record; + const keys = Object.keys(obj).sort(); + const parts: string[] = []; + for (const key of keys) { + const entry = obj[key]; + if (entry === undefined) { + // Skip undefined object properties, like JSON.stringify. + continue; + } + parts.push(`${JSON.stringify(key)}:${serialize(entry, `${path}.${key}`)}`); + } + return `{${parts.join(',')}}`; +} + +/** Lowercase hex sha256 of a string (UTF-8) or raw bytes. No prefix. */ +export function sha256Hex(input: string | Uint8Array): string { + const hash = createHash('sha256'); + if (typeof input === 'string') { + hash.update(input, 'utf8'); + } else { + hash.update(input); + } + return hash.digest('hex'); +} + +/** Content hash of a value's canonical JSON, formatted as `sha256:`. */ +export function canonicalHash(value: unknown): string { + return `sha256:${sha256Hex(canonicalJsonStringify(value))}`; +} + +/** + * Content hash of a value's canonical JSON formatted as a 0x-prefixed bytes32 + * hex string, for on-chain fields (evidenceHash, probeCommitment). + */ +export function canonicalHashBytes32(value: unknown): string { + return `0x${sha256Hex(canonicalJsonStringify(value))}`; +} diff --git a/packages/fingerprints/src/index.ts b/packages/fingerprints/src/index.ts new file mode 100644 index 000000000..dace0c7b1 --- /dev/null +++ b/packages/fingerprints/src/index.ts @@ -0,0 +1,10 @@ +/** + * @antseed/fingerprints — pure verifier math for AntSeed model verification. + * + * No P2P, SQLite, network, or provider code. See + * docs/protocol/spec/07-model-verification.md for the protocol context. + */ + +export * from './canonical-json.js'; +export * from './types.js'; +export * from './verifiers/kbf/index.js'; diff --git a/packages/fingerprints/src/types.ts b/packages/fingerprints/src/types.ts new file mode 100644 index 000000000..2b42e0f34 --- /dev/null +++ b/packages/fingerprints/src/types.ts @@ -0,0 +1,264 @@ +/** + * Shared schema types for the AntSeed fingerprint verifier suite. + * See docs/protocol/spec/07-model-verification.md. + */ + +import { canonicalHash, canonicalJsonStringify, sha256Hex } from './canonical-json.js'; + +export const FINGERPRINTS_PACKAGE_NAME = '@antseed/fingerprints'; +export const FINGERPRINTS_PACKAGE_VERSION = '0.1.0'; + +// --------------------------------------------------------------------------- +// Verdicts +// --------------------------------------------------------------------------- + +export type FingerprintVerdict = 'SAME' | 'DIFF' | 'UNDETERMINED' | 'UNKNOWN'; + +/** + * FROZEN on-chain enum mapping (matches the Solidity contract): + * UNKNOWN=0, SAME=1, DIFF=2, UNDETERMINED=3. + */ +const VERDICT_CODES: Record = { + UNKNOWN: 0, + SAME: 1, + DIFF: 2, + UNDETERMINED: 3, +}; + +const CODE_VERDICTS: Record = { + 0: 'UNKNOWN', + 1: 'SAME', + 2: 'DIFF', + 3: 'UNDETERMINED', +}; + +export function verdictToCode(verdict: FingerprintVerdict): number { + const code = VERDICT_CODES[verdict]; + if (code === undefined) { + throw new Error(`verdictToCode: unknown verdict "${verdict}"`); + } + return code; +} + +export function verdictFromCode(code: number): FingerprintVerdict { + const verdict = CODE_VERDICTS[code]; + if (verdict === undefined) { + throw new Error(`verdictFromCode: unknown verdict code ${code}`); + } + return verdict; +} + +// --------------------------------------------------------------------------- +// Probes +// --------------------------------------------------------------------------- + +export type ToleranceMode = 'absolute' | 'relative'; + +export interface ProbeTolerance { + mode: ToleranceMode; + value: number; +} + +/** + * A single KBF numeric cloze probe. `consensus` is the expected answer; + * during candidate generation it is advisory until the reference is certified. + * Unknown extension fields (e.g. `contrast`, `consensusRaw`) are preserved. + */ +export interface KbfProbe { + id: string; + name: string; + domain: string; + /** Cloze template containing `___` (and optionally `{name}`). */ + template: string; + consensus: number; + range: [number, number]; + tolerance: ProbeTolerance; + /** Optional contrast-model metadata, preserved verbatim. */ + contrast?: Record; + [extension: string]: unknown; +} + +// --------------------------------------------------------------------------- +// Fingerprint reference envelope +// --------------------------------------------------------------------------- + +export interface ReferenceGenerator { + name: string; + version: string; + verifierKind: string; + params: Record; + [extension: string]: unknown; +} + +export interface ReferenceProvenance { + license?: string; + url?: string; + commit?: string; + [extension: string]: unknown; +} + +export interface ReferenceSelfTest { + /** Mismatch count of the reference model against its own probe set. */ + hamming: number; + total: number; + coverage: number; + errorRate: number; + [extension: string]: unknown; +} + +/** + * Common reference envelope shared by all verifier kinds + * (spec 07 "Reference Schema"). Unknown extension fields are preserved. + */ +export interface FingerprintReference { + version: number; + kind: string; + referenceId: string; + referenceModel: string; + serviceAliases: string[]; + createdAt: string; + source: 'public' | 'generated' | 'imported'; + generator: ReferenceGenerator; + provenance?: ReferenceProvenance; + selfTest: ReferenceSelfTest; + probes: KbfProbe[]; + [extension: string]: unknown; +} + +/** + * Content-addressed reference id: canonical hash over the reference minus + * `referenceId` itself and any caller-declared local-only fields + * (e.g. local filesystem paths, which MUST NOT be hashed). + */ +export function computeReferenceId( + reference: FingerprintReference | Record, + localOnlyFields: string[] = [], +): string { + const copy: Record = { ...(reference as Record) }; + delete copy['referenceId']; + for (const field of localOnlyFields) { + delete copy[field]; + } + return canonicalHash(copy); +} + +// --------------------------------------------------------------------------- +// Probe sets (verifier-generated, committed on-chain pre-audit) +// --------------------------------------------------------------------------- + +export interface ProbeSet { + probeSetId: string; + service: string; + probes: KbfProbe[]; + nonce: string; + createdAt: string; +} + +/** + * Order-sensitive content id over `{ service, probes }` — the COMPLETE probe + * definitions, not just ids. Every scoring-relevant field (template, consensus, + * range, tolerance, extensions) is bound, so two sets that would score answers + * differently can never share an id. + */ +export function computeProbeSetId(service: string, probes: readonly KbfProbe[]): string { + return canonicalHash({ service, probes }); +} + +/** + * On-chain pre-audit commitment (bytes32): a standard hash commitment + * `commit = SHA-256(canonicalJson({service, probes, nonce}))` over the FULL + * ordered probe definitions. Binding the complete content (not merely probe + * ids) is what stops a verifier from committing, observing responses, and then + * tightening `tolerance` or altering `consensus`/`range`/`template` while the + * commitment still verifies. Binding comes from SHA-256 collision resistance; + * hiding from the 256-bit HKDF-derived nonce. Opened (probe set + nonce + * revealed) only after responses, so a verifier cannot cherry-pick or reshape + * probes after seeing answers. + */ +export function computeProbeCommitment( + probeSet: Pick, +): string { + return `0x${sha256Hex(canonicalProbeSetJson(probeSet))}`; +} + +/** + * THE canonical probe-set reveal bytes: the exact canonical-JSON string whose + * `SHA-256(utf8(...))` (0x-prefixed) IS `computeProbeCommitment` — i.e. the + * preimage a legacy regression fixture hashes against its pre-audit commitment. + * Single source of truth: `computeProbeCommitment` is DEFINED as the hash of + * exactly this string, so the commitment and the published reveal bytes can + * never diverge (divergence would make the on-chain reveal revert forever). + */ +export function canonicalProbeSetJson( + probeSet: Pick, +): string { + return canonicalJsonStringify({ + service: probeSet.service, + probes: probeSet.probes, + nonce: probeSet.nonce, + }); +} + +// --------------------------------------------------------------------------- +// Match vectors and observations +// --------------------------------------------------------------------------- + +/** Per-probe outcome: 1 match, 0 discrepancy, null not attempted due to transport failure. */ +export type MatchEntry = 1 | 0 | null; +export type MatchVector = MatchEntry[]; + +/** Runtime guard: exactly 1, 0, or null — arbitrary truthy values are NOT matches. */ +export function isMatchEntry(value: unknown): value is MatchEntry { + return value === 1 || value === 0 || value === null; +} + +/** + * Runtime guard for match vectors crossing the public API boundary + * (e.g. `FingerprintObservation.matchVector` supplied by a caller rather than + * derived via `computeMatchVector`). + */ +export function isMatchVector(value: unknown): value is MatchVector { + return Array.isArray(value) && value.every(isMatchEntry); +} + +/** Parsed answers or a precomputed match vector for one evaluation. */ +export interface FingerprintObservation { + /** Parsed numeric answers, position-aligned with the probe set. */ + answers: Array; + /** Optional precomputed match vector (otherwise derived from answers). */ + matchVector?: MatchVector; +} + +// --------------------------------------------------------------------------- +// Evaluation result +// --------------------------------------------------------------------------- + +export interface FingerprintVerifierInfo { + kind: string; + package: string; + version: string; +} + +export interface FingerprintStats { + selfHamming: number; + selfTotal: number; + targetHamming: number | null; + targetTotal: number | null; + selfCoverage: number; + targetCoverage: number | null; + p0Cp99: number | null; + pValueBinomial: number | null; +} + +export interface FingerprintEvaluation { + verifier: FingerprintVerifierInfo; + referenceId: string; + referenceModel: string; + probeCount: number; + parsedProbeCount: number; + matchVector: MatchVector; + matchVectorHash: string; + stats: FingerprintStats; + verdict: FingerprintVerdict; + verdictReason: string | null; +} diff --git a/packages/fingerprints/src/verifiers/kbf/index.ts b/packages/fingerprints/src/verifiers/kbf/index.ts new file mode 100644 index 000000000..9412e06bc --- /dev/null +++ b/packages/fingerprints/src/verifiers/kbf/index.ts @@ -0,0 +1,143 @@ +/** + * KBF (Knowledge Boundary Fingerprinting) verifier — spec 07 F1. + * Reference-based verification: the reference model's own self-test bounds + * the honest error rate; the target's mismatch count is binomial-tested + * against that bound. + */ + +import { + FINGERPRINTS_PACKAGE_NAME, + FINGERPRINTS_PACKAGE_VERSION, + isMatchVector, + type FingerprintEvaluation, + type FingerprintObservation, + type FingerprintStats, + type FingerprintReference, + type MatchVector, +} from '../../types.js'; +import { canonicalHash } from '../../canonical-json.js'; +import { computeMatchVector } from './scoring.js'; +import { computeKbfVerdict } from './verdict.js'; +import { subsetReferenceSelfTest, type KbfReferenceV1 } from './reference.js'; + +export * from './prompts.js'; +export * from './parser.js'; +export * from './scoring.js'; +export * from './stats.js'; +export * from './verdict.js'; +export * from './reference.js'; + +export const KBF_KIND = 'kbf'; + +export interface VerifyKbfOptions { + minCoverage?: number; + cpConfidence?: number; + alpha?: number; +} + +export function verifyKbf( + reference: FingerprintReference, + observation: FingerprintObservation, + options: VerifyKbfOptions = {}, +): FingerprintEvaluation { + const base = { + verifier: { + kind: KBF_KIND, + package: FINGERPRINTS_PACKAGE_NAME, + version: FINGERPRINTS_PACKAGE_VERSION, + }, + referenceId: reference.referenceId, + referenceModel: reference.referenceModel, + probeCount: reference.probes.length, + }; + + const unknown = (reason: string): FingerprintEvaluation => ({ + ...base, + parsedProbeCount: 0, + matchVector: [], + matchVectorHash: canonicalHash([]), + stats: emptyStats(reference), + verdict: 'UNKNOWN', + verdictReason: reason, + }); + + if (reference.kind !== KBF_KIND) { + return unknown(`reference kind "${reference.kind}" is not "${KBF_KIND}"`); + } + + let matchVector: MatchVector; + if (observation.matchVector) { + if (observation.matchVector.length !== reference.probes.length) { + return unknown( + `match vector length ${observation.matchVector.length} !== probe count ${reference.probes.length}`, + ); + } + // Caller-supplied vector: runtime-validate so arbitrary truthy entries + // cannot pass as matches. + if (!isMatchVector(observation.matchVector)) { + return unknown('match vector entries must be exactly 0, 1, or null'); + } + matchVector = observation.matchVector; + } else { + if (observation.answers.length !== reference.probes.length) { + return unknown( + `answers length ${observation.answers.length} !== probe count ${reference.probes.length}`, + ); + } + matchVector = computeMatchVector(observation.answers, reference.probes); + } + + let selectedSelfTest; + try { + selectedSelfTest = subsetReferenceSelfTest( + reference as KbfReferenceV1, + reference.probes.map((probe) => probe.id), + ); + } catch (error) { + return unknown(`invalid reference self-test: ${error instanceof Error ? error.message : String(error)}`); + } + const { verdict, verdictReason, stats } = computeKbfVerdict({ + selfHamming: selectedSelfTest.hamming, + selfTotal: selectedSelfTest.total, + targetMatchVector: matchVector, + minCoverage: options.minCoverage, + cpConfidence: options.cpConfidence, + alpha: options.alpha, + }); + + const parsedProbeCount = matchVector.filter((entry) => entry !== null).length; + + const fingerprintStats: FingerprintStats = { + selfHamming: stats.selfHamming, + selfTotal: stats.selfTotal, + targetHamming: stats.targetHamming, + targetTotal: stats.targetTotal, + selfCoverage: selectedSelfTest.coverage, + targetCoverage: stats.targetCoverage, + p0Cp99: stats.p0Cp99, + pValueBinomial: stats.pValueBinomial, + }; + + return { + ...base, + parsedProbeCount, + matchVector, + matchVectorHash: canonicalHash(matchVector), + stats: fingerprintStats, + verdict, + verdictReason, + }; +} + +function emptyStats(reference: FingerprintReference): FingerprintStats { + return { + selfHamming: reference.selfTest.hamming, + selfTotal: reference.selfTest.total, + targetHamming: null, + targetTotal: null, + selfCoverage: reference.selfTest.coverage, + targetCoverage: null, + p0Cp99: null, + pValueBinomial: null, + }; +} diff --git a/packages/fingerprints/src/verifiers/kbf/parser.ts b/packages/fingerprints/src/verifiers/kbf/parser.ts new file mode 100644 index 000000000..b69ebbe60 --- /dev/null +++ b/packages/fingerprints/src/verifiers/kbf/parser.ts @@ -0,0 +1,58 @@ +/** + * Position-aware numeric answer parsing for KBF responses. + * + * Answers are extracted from `(N) ` lines. Position comes from the + * `(N)` index, never from the line order, so missing or reordered lines do + * not shift other answers. Unparseable or missing positions yield null. + */ + +/** + * Matches a line that starts with an answer index: `(3)`, `3)`, `3.`, `(3):` + * etc. Captures the index and the remainder of the line. `-` is deliberately + * not a separator so negative answers like `(5)-430` keep their sign. + */ +const ANSWER_LINE_RE = /^\s*\(?\s*(\d{1,4})\s*[)\].:;]+\s*(.*)$/; + +/** + * First numeric token in a string: optional sign, digits with optional + * thousands-commas, optional decimals, optional scientific exponent. + * Trailing units (`°C`, `km`, …) and leading prose are tolerated. + */ +const NUMBER_RE = /[-+]?\d[\d,]*(?:\.\d+)?(?:[eE][-+]?\d+)?/; + +function parseNumericToken(text: string): number | null { + const match = NUMBER_RE.exec(text); + if (!match) { + return null; + } + const cleaned = match[0].replace(/,/g, ''); + const value = Number.parseFloat(cleaned); + return Number.isFinite(value) ? value : null; +} + +/** + * Parse a KBF batch response into a position-aligned answer array of length + * `probeCount`. Index N (1-based in the response) maps to slot N-1. + * The first parsed value wins if an index repeats. + */ +export function parseKbfAnswers(responseText: string, probeCount: number): Array { + const answers: Array = new Array(probeCount).fill(null); + if (probeCount <= 0) { + return answers; + } + for (const line of responseText.split(/\r?\n/)) { + const lineMatch = ANSWER_LINE_RE.exec(line); + if (!lineMatch) { + continue; + } + const index = Number.parseInt(lineMatch[1]!, 10); + if (!Number.isInteger(index) || index < 1 || index > probeCount) { + continue; + } + if (answers[index - 1] !== null) { + continue; // first parsed value wins + } + answers[index - 1] = parseNumericToken(lineMatch[2] ?? ''); + } + return answers; +} diff --git a/packages/fingerprints/src/verifiers/kbf/prompts.ts b/packages/fingerprints/src/verifiers/kbf/prompts.ts new file mode 100644 index 000000000..7ade1cbd8 --- /dev/null +++ b/packages/fingerprints/src/verifiers/kbf/prompts.ts @@ -0,0 +1,67 @@ +/** + * KBF prompt construction: numeric cloze batch prompts and + * OpenAI-compatible chat request bodies. + */ + +import type { KbfProbe } from '../../types.js'; + +export const KBF_SYSTEM_PROMPT = + "Follow the user's instructions exactly. Output only what is requested."; + +const KBF_TASK_HEADER = + 'TASK: Answer these factual recall questions using only values stored in your weights.\n' + + 'RULES: Output ONLY in (N) format, one per line. ' + + 'Give a single plain number per line, no words, no ranges. ' + + 'If unsure, output your best single numeric estimate.'; + +/** Render a probe's cloze line, substituting `{name}` when present. */ +export function renderKbfProbeLine(probe: KbfProbe): string { + return probe.template.includes('{name}') + ? probe.template.split('{name}').join(probe.name) + : probe.template; +} + +/** + * Build the numeric cloze batch prompt: + * TASK/RULES header followed by numbered `(N)