@iam-protocol/pulse-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ import type { SolanaProof } from "../proof/types";
2
+ import type { SubmissionResult } from "./types";
3
+
4
+ /**
5
+ * Submit a proof via the IAM relayer API (walletless mode).
6
+ * The relayer submits the on-chain transaction using the integrator's funded account.
7
+ * The user needs no wallet, no SOL, no crypto knowledge.
8
+ *
9
+ * In Phase 3, the relayer endpoint is configurable (stub until executor-node in Phase 4).
10
+ */
11
+ export async function submitViaRelayer(
12
+ proof: SolanaProof,
13
+ commitment: Uint8Array,
14
+ options: {
15
+ relayerUrl: string;
16
+ apiKey?: string;
17
+ isFirstVerification: boolean;
18
+ }
19
+ ): Promise<SubmissionResult> {
20
+ try {
21
+ const body = {
22
+ proof_bytes: Array.from(proof.proofBytes),
23
+ public_inputs: proof.publicInputs.map((pi) => Array.from(pi)),
24
+ commitment: Array.from(commitment),
25
+ is_first_verification: options.isFirstVerification,
26
+ };
27
+
28
+ const headers: Record<string, string> = {
29
+ "Content-Type": "application/json",
30
+ };
31
+
32
+ if (options.apiKey) {
33
+ headers["X-API-Key"] = options.apiKey;
34
+ }
35
+
36
+ const response = await fetch(options.relayerUrl, {
37
+ method: "POST",
38
+ headers,
39
+ body: JSON.stringify(body),
40
+ });
41
+
42
+ if (!response.ok) {
43
+ const errorText = await response.text();
44
+ return { success: false, error: `Relayer error: ${response.status} ${errorText}` };
45
+ }
46
+
47
+ const result = (await response.json()) as {
48
+ success?: boolean;
49
+ tx_signature?: string;
50
+ };
51
+ return {
52
+ success: result.success ?? true,
53
+ txSignature: result.tx_signature,
54
+ };
55
+ } catch (err: any) {
56
+ return { success: false, error: err.message ?? String(err) };
57
+ }
58
+ }
@@ -0,0 +1,15 @@
1
+ /** Result of a verification submission */
2
+ export interface SubmissionResult {
3
+ success: boolean;
4
+ txSignature?: string;
5
+ error?: string;
6
+ }
7
+
8
+ /** Result of a full Pulse verification */
9
+ export interface VerificationResult {
10
+ success: boolean;
11
+ commitment: Uint8Array;
12
+ txSignature?: string;
13
+ isFirstVerification: boolean;
14
+ error?: string;
15
+ }
@@ -0,0 +1,167 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // Anchor program interactions use runtime IDL fetching, requiring dynamic typing.
3
+ import type { SolanaProof } from "../proof/types";
4
+ import type { SubmissionResult } from "./types";
5
+ import { PROGRAM_IDS } from "../config";
6
+
7
+ /**
8
+ * Submit a proof on-chain via a connected wallet (wallet-connected mode).
9
+ * Uses Anchor SDK to construct and send the transaction.
10
+ *
11
+ * Flow: create_challenge → verify_proof → update_anchor (or mint_anchor for first time)
12
+ */
13
+ export async function submitViaWallet(
14
+ proof: SolanaProof,
15
+ commitment: Uint8Array,
16
+ options: {
17
+ wallet: any; // WalletAdapter
18
+ connection: any; // Connection
19
+ isFirstVerification: boolean;
20
+ trustScore?: number;
21
+ }
22
+ ): Promise<SubmissionResult> {
23
+ try {
24
+ const anchor = await import("@coral-xyz/anchor");
25
+ const { PublicKey, SystemProgram } = await import("@solana/web3.js");
26
+
27
+ const provider = new anchor.AnchorProvider(
28
+ options.connection,
29
+ options.wallet,
30
+ { commitment: "confirmed" }
31
+ );
32
+
33
+ const verifierProgramId = new PublicKey(PROGRAM_IDS.iamVerifier);
34
+ const anchorProgramId = new PublicKey(PROGRAM_IDS.iamAnchor);
35
+
36
+ // Generate nonce for challenge
37
+ const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)));
38
+
39
+ // Derive PDAs
40
+ const [challengePda] = PublicKey.findProgramAddressSync(
41
+ [
42
+ Buffer.from("challenge"),
43
+ provider.wallet.publicKey.toBuffer(),
44
+ Buffer.from(nonce),
45
+ ],
46
+ verifierProgramId
47
+ );
48
+
49
+ const [verificationPda] = PublicKey.findProgramAddressSync(
50
+ [
51
+ Buffer.from("verification"),
52
+ provider.wallet.publicKey.toBuffer(),
53
+ Buffer.from(nonce),
54
+ ],
55
+ verifierProgramId
56
+ );
57
+
58
+ // Build and send create_challenge + verify_proof transactions
59
+ // These use the raw Anchor program interface
60
+ const verifierIdl = await anchor.Program.fetchIdl(
61
+ verifierProgramId,
62
+ provider
63
+ );
64
+ if (!verifierIdl) {
65
+ return { success: false, error: "Failed to fetch verifier IDL" };
66
+ }
67
+
68
+ const verifierProgram: any = new anchor.Program(
69
+ verifierIdl,
70
+ provider
71
+ );
72
+
73
+ // 1. Create challenge
74
+ await verifierProgram.methods
75
+ .createChallenge(nonce)
76
+ .accounts({
77
+ challenger: provider.wallet.publicKey,
78
+ challenge: challengePda,
79
+ systemProgram: SystemProgram.programId,
80
+ })
81
+ .rpc();
82
+
83
+ // 2. Verify proof
84
+ const txSig = await verifierProgram.methods
85
+ .verifyProof(
86
+ Buffer.from(proof.proofBytes),
87
+ proof.publicInputs.map((pi) => Array.from(pi)),
88
+ nonce
89
+ )
90
+ .accounts({
91
+ verifier: provider.wallet.publicKey,
92
+ challenge: challengePda,
93
+ verificationResult: verificationPda,
94
+ systemProgram: SystemProgram.programId,
95
+ })
96
+ .rpc();
97
+
98
+ // 3. Mint or update anchor
99
+ const anchorIdl = await anchor.Program.fetchIdl(anchorProgramId, provider);
100
+ if (anchorIdl) {
101
+ const anchorProgram: any = new anchor.Program(anchorIdl, provider);
102
+
103
+ if (options.isFirstVerification) {
104
+ const [identityPda] = PublicKey.findProgramAddressSync(
105
+ [Buffer.from("identity"), provider.wallet.publicKey.toBuffer()],
106
+ anchorProgramId
107
+ );
108
+ const [mintPda] = PublicKey.findProgramAddressSync(
109
+ [Buffer.from("mint"), provider.wallet.publicKey.toBuffer()],
110
+ anchorProgramId
111
+ );
112
+ const [mintAuthority] = PublicKey.findProgramAddressSync(
113
+ [Buffer.from("mint_authority")],
114
+ anchorProgramId
115
+ );
116
+
117
+ // Token-2022 program ID
118
+ const TOKEN_2022_PROGRAM_ID = new PublicKey(
119
+ "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
120
+ );
121
+
122
+ const { getAssociatedTokenAddressSync } = await import(
123
+ "@solana/spl-token"
124
+ );
125
+ const ata = getAssociatedTokenAddressSync(
126
+ mintPda,
127
+ provider.wallet.publicKey,
128
+ false,
129
+ TOKEN_2022_PROGRAM_ID
130
+ );
131
+
132
+ await anchorProgram.methods
133
+ .mintAnchor(Array.from(commitment))
134
+ .accounts({
135
+ user: provider.wallet.publicKey,
136
+ identityState: identityPda,
137
+ mint: mintPda,
138
+ mintAuthority,
139
+ tokenAccount: ata,
140
+ associatedTokenProgram: new PublicKey(
141
+ "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
142
+ ),
143
+ tokenProgram: TOKEN_2022_PROGRAM_ID,
144
+ systemProgram: SystemProgram.programId,
145
+ })
146
+ .rpc();
147
+ } else {
148
+ const [identityPda] = PublicKey.findProgramAddressSync(
149
+ [Buffer.from("identity"), provider.wallet.publicKey.toBuffer()],
150
+ anchorProgramId
151
+ );
152
+
153
+ await anchorProgram.methods
154
+ .updateAnchor(Array.from(commitment), options.trustScore ?? 0)
155
+ .accounts({
156
+ authority: provider.wallet.publicKey,
157
+ identityState: identityPda,
158
+ })
159
+ .rpc();
160
+ }
161
+ }
162
+
163
+ return { success: true, txSignature: txSig };
164
+ } catch (err: any) {
165
+ return { success: false, error: err.message ?? String(err) };
166
+ }
167
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ declare module "snarkjs" {
2
+ export const groth16: {
3
+ fullProve(
4
+ input: any,
5
+ wasmPath: string,
6
+ zkeyPath: string
7
+ ): Promise<{ proof: any; publicSignals: string[] }>;
8
+ verify(vk: any, publicSignals: string[], proof: any): Promise<boolean>;
9
+ };
10
+ }
11
+
12
+ declare module "circomlibjs" {
13
+ export function buildPoseidon(): Promise<any>;
14
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as path from "path";
3
+ import * as fs from "fs";
4
+ import { simhash, hammingDistance } from "../src/hashing/simhash";
5
+ import { generateTBH, computeCommitment } from "../src/hashing/poseidon";
6
+ import { prepareCircuitInput, generateProof } from "../src/proof/prover";
7
+ import { serializeProof } from "../src/proof/serializer";
8
+ import { TOTAL_PROOF_SIZE, NUM_PUBLIC_INPUTS } from "../src/config";
9
+
10
+ // Circuit artifacts from adjacent circuits repo
11
+ const WASM_PATH = path.resolve(
12
+ __dirname,
13
+ "../../circuits/build/iam_hamming_js/iam_hamming.wasm"
14
+ );
15
+ const ZKEY_PATH = path.resolve(
16
+ __dirname,
17
+ "../../circuits/build/iam_hamming_final.zkey"
18
+ );
19
+ const VK_PATH = path.resolve(
20
+ __dirname,
21
+ "../../circuits/keys/verification_key.json"
22
+ );
23
+
24
+ // Skip tests if circuit artifacts aren't built
25
+ const circuitArtifactsExist =
26
+ fs.existsSync(WASM_PATH) && fs.existsSync(ZKEY_PATH) && fs.existsSync(VK_PATH);
27
+
28
+ describe.skipIf(!circuitArtifactsExist)(
29
+ "integration: full crypto pipeline",
30
+ () => {
31
+ it("generates a valid proof from mock features end-to-end", async () => {
32
+ // 1. Create mock feature vector (~236 random values)
33
+ const features = Array.from({ length: 236 }, (_, i) =>
34
+ Math.sin(i * 0.3) * Math.cos(i * 0.7)
35
+ );
36
+
37
+ // 2. SimHash → 256-bit fingerprint
38
+ const fpNew = simhash(features);
39
+ expect(fpNew.length).toBe(256);
40
+
41
+ // 3. Create "previous" fingerprint by flipping 10 bits
42
+ const fpPrev = [...fpNew];
43
+ for (let i = 0; i < 10; i++) {
44
+ fpPrev[i * 25] = fpPrev[i * 25] === 1 ? 0 : 1;
45
+ }
46
+ expect(hammingDistance(fpNew, fpPrev)).toBe(10);
47
+
48
+ // 4. Generate TBHs with Poseidon commitments
49
+ const tbhNew = await generateTBH(fpNew);
50
+ const tbhPrev = await generateTBH(fpPrev);
51
+
52
+ expect(tbhNew.commitment).toBeGreaterThan(BigInt(0));
53
+ expect(tbhPrev.commitment).toBeGreaterThan(BigInt(0));
54
+
55
+ // 5. Prepare circuit input
56
+ const input = prepareCircuitInput(tbhNew, tbhPrev, 30);
57
+ expect(input.ft_new.length).toBe(256);
58
+ expect(input.threshold).toBe("30");
59
+
60
+ // 6. Generate Groth16 proof
61
+ const { proof, publicSignals } = await generateProof(
62
+ input,
63
+ WASM_PATH,
64
+ ZKEY_PATH
65
+ );
66
+ expect(publicSignals.length).toBe(NUM_PUBLIC_INPUTS);
67
+
68
+ // 7. Serialize for Solana
69
+ const { proofBytes, publicInputs } = serializeProof(
70
+ proof,
71
+ publicSignals
72
+ );
73
+ expect(proofBytes.length).toBe(TOTAL_PROOF_SIZE);
74
+ expect(publicInputs.length).toBe(NUM_PUBLIC_INPUTS);
75
+ for (const input of publicInputs) {
76
+ expect(input.length).toBe(32);
77
+ }
78
+
79
+ // 8. Verify locally
80
+ const snarkjs = await import("snarkjs");
81
+ const vk = JSON.parse(fs.readFileSync(VK_PATH, "utf-8"));
82
+ const valid = await snarkjs.groth16.verify(vk, publicSignals, proof);
83
+ expect(valid).toBe(true);
84
+ });
85
+
86
+ it("fails proof generation when distance exceeds threshold", async () => {
87
+ // Create fingerprints with ~200 bits different
88
+ const fpNew = Array.from({ length: 256 }, () =>
89
+ Math.random() > 0.5 ? 1 : 0
90
+ );
91
+ const fpPrev = fpNew.map((b) => (Math.random() > 0.2 ? 1 - b : b));
92
+
93
+ const tbhNew = await generateTBH(fpNew);
94
+ const tbhPrev = await generateTBH(fpPrev);
95
+ const input = prepareCircuitInput(tbhNew, tbhPrev, 30);
96
+
97
+ await expect(
98
+ generateProof(input, WASM_PATH, ZKEY_PATH)
99
+ ).rejects.toThrow();
100
+ });
101
+ }
102
+ );
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ computeCommitment,
4
+ generateSalt,
5
+ packBits,
6
+ bigintToBytes32,
7
+ generateTBH,
8
+ } from "../src/hashing/poseidon";
9
+ import { FINGERPRINT_BITS } from "../src/config";
10
+
11
+ describe("poseidon", () => {
12
+ const testFingerprint = Array.from({ length: FINGERPRINT_BITS }, (_, i) =>
13
+ i % 3 === 0 ? 1 : 0
14
+ );
15
+ const testSalt = BigInt("12345678901234567890");
16
+
17
+ it("packs bits into two 128-bit field elements (little-endian)", () => {
18
+ const bits = new Array(FINGERPRINT_BITS).fill(0);
19
+ bits[0] = 1; // bit 0 → lo = 1
20
+ bits[128] = 1; // bit 128 → hi = 1
21
+
22
+ const { lo, hi } = packBits(bits);
23
+ expect(lo).toBe(BigInt(1));
24
+ expect(hi).toBe(BigInt(1));
25
+ });
26
+
27
+ it("packs complex bit patterns correctly", () => {
28
+ const bits = new Array(FINGERPRINT_BITS).fill(0);
29
+ bits[0] = 1;
30
+ bits[1] = 1;
31
+ bits[7] = 1;
32
+ // lo should be 1 + 2 + 128 = 131
33
+ const { lo } = packBits(bits);
34
+ expect(lo).toBe(BigInt(131));
35
+ });
36
+
37
+ it("computes deterministic commitment", async () => {
38
+ const c1 = await computeCommitment(testFingerprint, testSalt);
39
+ const c2 = await computeCommitment(testFingerprint, testSalt);
40
+ expect(c1).toBe(c2);
41
+ });
42
+
43
+ it("different salts produce different commitments", async () => {
44
+ const c1 = await computeCommitment(testFingerprint, testSalt);
45
+ const c2 = await computeCommitment(testFingerprint, testSalt + BigInt(1));
46
+ expect(c1).not.toBe(c2);
47
+ });
48
+
49
+ it("different fingerprints produce different commitments", async () => {
50
+ const fp2 = [...testFingerprint];
51
+ fp2[0] = fp2[0] === 1 ? 0 : 1;
52
+ const c1 = await computeCommitment(testFingerprint, testSalt);
53
+ const c2 = await computeCommitment(fp2, testSalt);
54
+ expect(c1).not.toBe(c2);
55
+ });
56
+
57
+ it("generates salt within BN254 scalar field", () => {
58
+ const salt = generateSalt();
59
+ expect(salt).toBeGreaterThan(BigInt(0));
60
+ expect(salt).toBeLessThan(
61
+ BigInt(
62
+ "21888242871839275222246405745257275088548364400416034343698204186575808495617"
63
+ )
64
+ );
65
+ });
66
+
67
+ it("converts bigint to 32-byte big-endian", () => {
68
+ const bytes = bigintToBytes32(BigInt(256));
69
+ expect(bytes[30]).toBe(1);
70
+ expect(bytes[31]).toBe(0);
71
+ expect(bytes.length).toBe(32);
72
+ });
73
+
74
+ it("generates complete TBH", async () => {
75
+ const tbh = await generateTBH(testFingerprint);
76
+ expect(tbh.fingerprint).toEqual(testFingerprint);
77
+ expect(tbh.salt).toBeGreaterThan(BigInt(0));
78
+ expect(tbh.commitment).toBeGreaterThan(BigInt(0));
79
+ expect(tbh.commitmentBytes.length).toBe(32);
80
+ });
81
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { serializeProof, toBigEndian32 } from "../src/proof/serializer";
3
+ import { TOTAL_PROOF_SIZE, NUM_PUBLIC_INPUTS, BN254_BASE_FIELD } from "../src/config";
4
+
5
+ // Mock proof matching snarkjs output format
6
+ const mockProof = {
7
+ pi_a: [
8
+ "12345678901234567890123456789012345678901234567890",
9
+ "98765432109876543210987654321098765432109876543210",
10
+ "1",
11
+ ],
12
+ pi_b: [
13
+ [
14
+ "11111111111111111111111111111111111111111111111111",
15
+ "22222222222222222222222222222222222222222222222222",
16
+ ],
17
+ [
18
+ "33333333333333333333333333333333333333333333333333",
19
+ "44444444444444444444444444444444444444444444444444",
20
+ ],
21
+ ],
22
+ pi_c: [
23
+ "55555555555555555555555555555555555555555555555555",
24
+ "66666666666666666666666666666666666666666666666666",
25
+ ],
26
+ protocol: "groth16",
27
+ curve: "bn128",
28
+ };
29
+
30
+ const mockPublicSignals = [
31
+ "111111111111111111111",
32
+ "222222222222222222222",
33
+ "30",
34
+ ];
35
+
36
+ describe("serializer", () => {
37
+ it("toBigEndian32 converts decimal string to 32 bytes", () => {
38
+ const bytes = toBigEndian32("256");
39
+ expect(bytes.length).toBe(32);
40
+ expect(bytes[30]).toBe(1);
41
+ expect(bytes[31]).toBe(0);
42
+ });
43
+
44
+ it("toBigEndian32 handles zero", () => {
45
+ const bytes = toBigEndian32("0");
46
+ expect(bytes.every((b) => b === 0)).toBe(true);
47
+ });
48
+
49
+ it("produces 256-byte proof output", () => {
50
+ const { proofBytes } = serializeProof(mockProof as any, mockPublicSignals);
51
+ expect(proofBytes.length).toBe(TOTAL_PROOF_SIZE);
52
+ });
53
+
54
+ it("produces correct number of public inputs", () => {
55
+ const { publicInputs } = serializeProof(mockProof as any, mockPublicSignals);
56
+ expect(publicInputs.length).toBe(NUM_PUBLIC_INPUTS);
57
+ for (const input of publicInputs) {
58
+ expect(input.length).toBe(32);
59
+ }
60
+ });
61
+
62
+ it("negates proof_a y-coordinate", () => {
63
+ const { proofBytes } = serializeProof(mockProof as any, mockPublicSignals);
64
+
65
+ // Extract the y-coordinate from proof_a (bytes 32-63)
66
+ let yFromProof = BigInt(0);
67
+ for (let i = 32; i < 64; i++) {
68
+ yFromProof = (yFromProof << BigInt(8)) + BigInt(proofBytes[i]!);
69
+ }
70
+
71
+ // The original y
72
+ const yOriginal = BigInt(mockProof.pi_a[1]!);
73
+ const yExpected = (BN254_BASE_FIELD - yOriginal) % BN254_BASE_FIELD;
74
+
75
+ expect(yFromProof).toBe(yExpected);
76
+ });
77
+
78
+ it("reverses G2 coordinate ordering in proof_b", () => {
79
+ const { proofBytes } = serializeProof(mockProof as any, mockPublicSignals);
80
+
81
+ // First 32 bytes of proof_b should be pi_b[0][1] (c1), not pi_b[0][0] (c0)
82
+ const expectedFirst = toBigEndian32(mockProof.pi_b[0]![1]!);
83
+ const actualFirst = proofBytes.slice(64, 96);
84
+ expect(Array.from(actualFirst)).toEqual(Array.from(expectedFirst));
85
+ });
86
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { simhash, hammingDistance } from "../src/hashing/simhash";
3
+ import { FINGERPRINT_BITS } from "../src/config";
4
+
5
+ describe("simhash", () => {
6
+ const featureA = Array.from({ length: 100 }, (_, i) => Math.sin(i * 0.1));
7
+
8
+ it("produces a 256-bit binary fingerprint", () => {
9
+ const fp = simhash(featureA);
10
+ expect(fp.length).toBe(FINGERPRINT_BITS);
11
+ for (const bit of fp) {
12
+ expect(bit === 0 || bit === 1).toBe(true);
13
+ }
14
+ });
15
+
16
+ it("is deterministic", () => {
17
+ const fp1 = simhash(featureA);
18
+ const fp2 = simhash(featureA);
19
+ expect(fp1).toEqual(fp2);
20
+ });
21
+
22
+ it("similar vectors produce low Hamming distance", () => {
23
+ // Slightly perturbed version of featureA
24
+ const featureB = featureA.map((v) => v + (Math.random() - 0.5) * 0.01);
25
+ const fpA = simhash(featureA);
26
+ const fpB = simhash(featureB);
27
+ const dist = hammingDistance(fpA, fpB);
28
+ // Small perturbation should produce distance well below 128 (random chance)
29
+ expect(dist).toBeLessThan(64);
30
+ });
31
+
32
+ it("dissimilar vectors produce high Hamming distance", () => {
33
+ const featureC = Array.from({ length: 100 }, (_, i) => -Math.cos(i * 3.7));
34
+ const fpA = simhash(featureA);
35
+ const fpC = simhash(featureC);
36
+ const dist = hammingDistance(fpA, fpC);
37
+ // Different vectors should have distance closer to 128 (random)
38
+ expect(dist).toBeGreaterThan(50);
39
+ });
40
+
41
+ it("empty feature vector returns all zeros", () => {
42
+ const fp = simhash([]);
43
+ expect(fp.length).toBe(FINGERPRINT_BITS);
44
+ expect(fp.every((b) => b === 0)).toBe(true);
45
+ });
46
+
47
+ it("hamming distance is symmetric", () => {
48
+ const fpA = simhash(featureA);
49
+ const fpB = simhash(featureA.map((v) => v + 0.5));
50
+ expect(hammingDistance(fpA, fpB)).toBe(hammingDistance(fpB, fpA));
51
+ });
52
+
53
+ it("hamming distance of identical fingerprints is zero", () => {
54
+ const fp = simhash(featureA);
55
+ expect(hammingDistance(fp, fp)).toBe(0);
56
+ });
57
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mean, variance, skewness, kurtosis, condense } from "../src/extraction/statistics";
3
+
4
+ describe("statistics", () => {
5
+ it("computes mean correctly", () => {
6
+ expect(mean([1, 2, 3, 4, 5])).toBe(3);
7
+ expect(mean([10])).toBe(10);
8
+ expect(mean([])).toBe(0);
9
+ });
10
+
11
+ it("computes variance correctly", () => {
12
+ // Sample variance of [2, 4, 4, 4, 5, 5, 7, 9]
13
+ const v = variance([2, 4, 4, 4, 5, 5, 7, 9]);
14
+ expect(v).toBeCloseTo(4.571, 2);
15
+ });
16
+
17
+ it("returns zero variance for constant array", () => {
18
+ expect(variance([5, 5, 5, 5])).toBe(0);
19
+ });
20
+
21
+ it("computes skewness correctly", () => {
22
+ // Symmetric distribution has ~0 skewness
23
+ const s = skewness([1, 2, 3, 4, 5]);
24
+ expect(Math.abs(s)).toBeLessThan(0.01);
25
+ });
26
+
27
+ it("computes positive skewness for right-skewed data", () => {
28
+ const s = skewness([1, 1, 1, 1, 1, 1, 1, 10]);
29
+ expect(s).toBeGreaterThan(0);
30
+ });
31
+
32
+ it("computes kurtosis for normal-like data", () => {
33
+ // Excess kurtosis near 0 for normal-like data
34
+ const k = kurtosis([2, 3, 4, 4, 5, 5, 5, 6, 6, 7, 7, 8]);
35
+ expect(Math.abs(k)).toBeLessThan(2);
36
+ });
37
+
38
+ it("condense returns all four stats", () => {
39
+ const result = condense([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
40
+ expect(result.mean).toBeCloseTo(5.5);
41
+ expect(result.variance).toBeGreaterThan(0);
42
+ expect(typeof result.skewness).toBe("number");
43
+ expect(typeof result.kurtosis).toBe("number");
44
+ });
45
+
46
+ it("handles edge cases", () => {
47
+ expect(variance([1])).toBe(0);
48
+ expect(skewness([1, 2])).toBe(0);
49
+ expect(kurtosis([1, 2, 3])).toBe(0);
50
+ });
51
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2020", "DOM"],
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "noImplicitReturns": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "esModuleInterop": true,
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true,
15
+ "outDir": "dist",
16
+ "rootDir": "src",
17
+ "skipLibCheck": true
18
+ },
19
+ "include": ["src/**/*.ts"],
20
+ "exclude": ["node_modules", "dist", "test"]
21
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts"],
5
+ format: ["esm", "cjs"],
6
+ dts: true,
7
+ splitting: false,
8
+ sourcemap: true,
9
+ clean: true,
10
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ testTimeout: 60000,
7
+ },
8
+ });