@@ -1250,9 +1250,24 @@ TEST_F(PerBlockGateCountTests, RealParallelChainedSha256)
12501250// with the first constraint of each type, while the parallel path creates them separately upfront via
12511251// prepare_builder_from_profiles. A precursor refactor to separate setup from execution in the sequential
12521252// path is needed first. See parallel_circuit_construction_poc.md "Path to production".
1253- TEST_F (PerBlockGateCountTests, DISABLED_BuildConstraintsParallel)
1253+ // Count the number of distinct copy cycles in a builder's union-find structure.
1254+ // A copy cycle is a set of variables that have been assert_equal'd together.
1255+ // We count by finding variables where real_variable_index[i] == i (cycle roots).
1256+ size_t count_copy_cycles (const UltraCircuitBuilder& builder)
1257+ {
1258+ size_t num_vars = builder.get_num_variables ();
1259+ // Count non-trivial cycles: variables where next_var_index != REAL_VARIABLE
1260+ // but that are the root (real_variable_index[i] == i)
1261+ std::set<uint32_t > roots;
1262+ for (size_t i = 0 ; i < num_vars; i++) {
1263+ roots.insert (builder.real_variable_index [i]);
1264+ }
1265+ return roots.size ();
1266+ }
1267+
1268+ // Helper to build the test program: 3 SHA256 + 3 Poseidon2
1269+ AcirFormat build_sha256_poseidon2_test_program (WitnessVector& witness_out)
12541270{
1255- // Build a multi-opcode AcirProgram: 3 SHA256 + 3 Poseidon2
12561271 std::vector<Acir::Opcode> all_opcodes;
12571272
12581273 // 3 SHA256 compression constraints, each using 32 witnesses
@@ -1282,69 +1297,153 @@ TEST_F(PerBlockGateCountTests, DISABLED_BuildConstraintsParallel)
12821297 }
12831298
12841299 Acir::Circuit circuit = build_acir_circuit (all_opcodes);
1285- AcirFormat constraint_system = circuit_serde_to_acir_format (circuit);
1286- WitnessVector witness (120 , fr (0 ));
1287-
1288- // Build sequentially
1289- AcirProgram seq_program{ constraint_system, WitnessVector (witness) };
1290- auto seq_builder = create_circuit<UltraCircuitBuilder>(seq_program, ProgramMetadata{});
1291-
1292- // Build in parallel
1293- AcirFormat par_constraints = constraint_system; // copy
1294- UltraCircuitBuilder par_builder{ witness, par_constraints.public_inputs , false };
1295- build_constraints_parallel (par_builder, par_constraints, ProgramMetadata{}, /* num_threads=*/ 2 );
1296-
1297- // Verify both pass circuit checker
1298- bool seq_check = CircuitChecker::check (seq_builder);
1299- bool par_check = CircuitChecker::check (par_builder);
1300- info (" Sequential: " , seq_check ? " PASSED" : " FAILED" );
1301- info (" Parallel: " , par_check ? " PASSED" : " FAILED" );
1302- EXPECT_TRUE (seq_check);
1303- EXPECT_TRUE (par_check);
1300+ witness_out = WitnessVector (120 , fr (0 ));
1301+ return circuit_serde_to_acir_format (circuit);
1302+ }
13041303
1305- // Compare entire finalized circuit: every block's wires and selectors must be identical
1306- auto seq_blocks = seq_builder.blocks .get ();
1307- auto par_blocks = par_builder.blocks .get ();
1304+ // N=1 parallel vs N=2 parallel: should be bit-identical since both go through
1305+ // prepare_builder_from_profiles and execute_parallel.
1306+ TEST_F (PerBlockGateCountTests, ParallelN1vsN2BitIdentical)
1307+ {
1308+ WitnessVector witness;
1309+ AcirFormat constraint_system = build_sha256_poseidon2_test_program (witness);
1310+
1311+ // Build with 1 thread
1312+ AcirFormat n1_constraints = constraint_system;
1313+ UltraCircuitBuilder n1_builder{ WitnessVector (witness), n1_constraints.public_inputs , false };
1314+ build_constraints_parallel (n1_builder, n1_constraints, ProgramMetadata{}, /* num_threads=*/ 1 );
1315+
1316+ // Build with 2 threads
1317+ AcirFormat n2_constraints = constraint_system;
1318+ UltraCircuitBuilder n2_builder{ WitnessVector (witness), n2_constraints.public_inputs , false };
1319+ build_constraints_parallel (n2_builder, n2_constraints, ProgramMetadata{}, /* num_threads=*/ 2 );
1320+
1321+ // Both must pass circuit checker
1322+ EXPECT_TRUE (CircuitChecker::check (n1_builder));
1323+ EXPECT_TRUE (CircuitChecker::check (n2_builder));
1324+
1325+ // Bit-identical: every block's wires and selectors must match
1326+ auto n1_blocks = n1_builder.blocks .get ();
1327+ auto n2_blocks = n2_builder.blocks .get ();
13081328 for (size_t b = 0 ; b < UltraCircuitBuilder::ExecutionTrace::NUM_BLOCKS; b++) {
1309- EXPECT_EQ (seq_blocks [b].size (), par_blocks [b].size ()) << " block " << b << " size mismatch" ;
1310- size_t count = std::min (seq_blocks [b].size (), par_blocks [b].size ());
1329+ EXPECT_EQ (n1_blocks [b].size (), n2_blocks [b].size ()) << " block " << b << " size mismatch" ;
1330+ size_t count = std::min (n1_blocks [b].size (), n2_blocks [b].size ());
13111331
1312- // Compare wires
13131332 size_t wire_mismatches = 0 ;
13141333 for (size_t w = 0 ; w < 4 ; w++) {
13151334 for (size_t i = 0 ; i < count; i++) {
1316- if (seq_blocks [b].wires [w][i] != par_blocks [b].wires [w][i]) {
1335+ if (n1_blocks [b].wires [w][i] != n2_blocks [b].wires [w][i])
13171336 wire_mismatches++;
1318- }
13191337 }
13201338 }
13211339 EXPECT_EQ (wire_mismatches, 0 ) << " block " << b << " : " << wire_mismatches << " wire mismatches" ;
13221340
1323- // Compare selectors
1324- auto seq_sels = seq_blocks[b].get_selectors ();
1325- auto par_sels = par_blocks[b].get_selectors ();
1341+ auto n1_sels = n1_blocks[b].get_selectors ();
1342+ auto n2_sels = n2_blocks[b].get_selectors ();
13261343 size_t sel_mismatches = 0 ;
1327- for (size_t s = 0 ; s < seq_sels .size (); s++) {
1344+ for (size_t s = 0 ; s < n1_sels .size (); s++) {
13281345 for (size_t i = 0 ; i < count; i++) {
1329- if (seq_sels [s][i] != par_sels [s][i]) {
1346+ if (n1_sels [s][i] != n2_sels [s][i])
13301347 sel_mismatches++;
1331- }
13321348 }
13331349 }
13341350 EXPECT_EQ (sel_mismatches, 0 ) << " block " << b << " : " << sel_mismatches << " selector mismatches" ;
13351351 }
13361352
1337- // Compare variable counts and union-find
1338- EXPECT_EQ (seq_builder.get_num_variables (), par_builder.get_num_variables ());
1339- size_t num_vars = std::min (seq_builder.get_num_variables (), par_builder.get_num_variables ());
1340-
1353+ // Variable counts and union-find must match exactly
1354+ EXPECT_EQ (n1_builder.get_num_variables (), n2_builder.get_num_variables ());
1355+ size_t num_vars = std::min (n1_builder.get_num_variables (), n2_builder.get_num_variables ());
13411356 size_t real_idx_mismatches = 0 ;
13421357 for (size_t i = 0 ; i < num_vars; i++) {
1343- if (seq_builder .real_variable_index [i] != par_builder .real_variable_index [i]) {
1358+ if (n1_builder .real_variable_index [i] != n2_builder .real_variable_index [i])
13441359 real_idx_mismatches++;
1345- }
13461360 }
13471361 EXPECT_EQ (real_idx_mismatches, 0 ) << " real_variable_index mismatches" ;
1362+ }
13481363
1349- info (" BuildConstraintsParallel: PASSED" );
1364+ // Sequential (build_constraints) vs parallel (build_constraints_parallel): circuits are NOT
1365+ // bit-identical because setup gates land in different positions. But the semantic invariants
1366+ // must hold: same block sizes, same variable/copy-cycle counts, same range lists, same constants,
1367+ // and both pass CircuitChecker.
1368+ TEST_F (PerBlockGateCountTests, SequentialVsParallelSemanticEquivalence)
1369+ {
1370+ WitnessVector witness;
1371+ AcirFormat constraint_system = build_sha256_poseidon2_test_program (witness);
1372+
1373+ // Build sequentially via create_circuit (uses build_constraints)
1374+ AcirProgram seq_program{ constraint_system, WitnessVector (witness) };
1375+ auto seq_builder = create_circuit<UltraCircuitBuilder>(seq_program, ProgramMetadata{});
1376+
1377+ // Build via parallel path with 1 thread
1378+ AcirFormat par_constraints = constraint_system;
1379+ UltraCircuitBuilder par_builder{ WitnessVector (witness), par_constraints.public_inputs , false };
1380+ build_constraints_parallel (par_builder, par_constraints, ProgramMetadata{}, /* num_threads=*/ 1 );
1381+
1382+ // Both must pass circuit checker
1383+ bool seq_check = CircuitChecker::check (seq_builder);
1384+ bool par_check = CircuitChecker::check (par_builder);
1385+ info (" Sequential CircuitChecker: " , seq_check ? " PASSED" : " FAILED" );
1386+ info (" Parallel CircuitChecker: " , par_check ? " PASSED" : " FAILED" );
1387+ EXPECT_TRUE (seq_check);
1388+ EXPECT_TRUE (par_check);
1389+
1390+ // --- Semantic invariants (same constraints, possibly different ordering) ---
1391+
1392+ // 1. Block sizes must match (same number of gates per block)
1393+ auto seq_blocks = seq_builder.blocks .get ();
1394+ auto par_blocks = par_builder.blocks .get ();
1395+ for (size_t b = 0 ; b < UltraCircuitBuilder::ExecutionTrace::NUM_BLOCKS; b++) {
1396+ EXPECT_EQ (seq_blocks[b].size (), par_blocks[b].size ())
1397+ << " block " << b << " size: seq=" << seq_blocks[b].size () << " par=" << par_blocks[b].size ();
1398+ }
1399+
1400+ // 2. Total variable count
1401+ info (" Variables: seq=" , seq_builder.get_num_variables (), " par=" , par_builder.get_num_variables ());
1402+ EXPECT_EQ (seq_builder.get_num_variables (), par_builder.get_num_variables ());
1403+
1404+ // 3. Number of distinct copy cycles (union-find roots)
1405+ size_t seq_cycles = count_copy_cycles (seq_builder);
1406+ size_t par_cycles = count_copy_cycles (par_builder);
1407+ info (" Copy cycle roots: seq=" , seq_cycles, " par=" , par_cycles);
1408+ EXPECT_EQ (seq_cycles, par_cycles);
1409+
1410+ // 4. Constant variable indices: same set of constant values registered
1411+ EXPECT_EQ (seq_builder.constant_variable_indices .size (), par_builder.constant_variable_indices .size ())
1412+ << " constant count: seq=" << seq_builder.constant_variable_indices .size ()
1413+ << " par=" << par_builder.constant_variable_indices .size ();
1414+ for (const auto & [value, _] : seq_builder.constant_variable_indices ) {
1415+ EXPECT_TRUE (par_builder.constant_variable_indices .count (value))
1416+ << " constant value " << value << " missing from parallel builder" ;
1417+ }
1418+
1419+ // 5. Range lists: same set of range targets with same variable counts
1420+ EXPECT_EQ (seq_builder.range_lists .size (), par_builder.range_lists .size ())
1421+ << " range list count: seq=" << seq_builder.range_lists .size () << " par=" << par_builder.range_lists .size ();
1422+ for (const auto & [target, seq_rl] : seq_builder.range_lists ) {
1423+ auto it = par_builder.range_lists .find (target);
1424+ EXPECT_TRUE (it != par_builder.range_lists .end ()) << " range target " << target << " missing from parallel" ;
1425+ if (it != par_builder.range_lists .end ()) {
1426+ EXPECT_EQ (seq_rl.variable_indices .size (), it->second .variable_indices .size ())
1427+ << " range target " << target << " variable count: seq=" << seq_rl.variable_indices .size ()
1428+ << " par=" << it->second .variable_indices .size ();
1429+ }
1430+ }
1431+
1432+ // 6. Lookup tables: same set of tables
1433+ EXPECT_EQ (seq_builder.get_lookup_tables ().size (), par_builder.get_lookup_tables ().size ())
1434+ << " lookup table count: seq=" << seq_builder.get_lookup_tables ().size ()
1435+ << " par=" << par_builder.get_lookup_tables ().size ();
1436+
1437+ // Report wire-level differences (informational, not assertions — we expect differences)
1438+ size_t total_wire_diffs = 0 ;
1439+ for (size_t b = 0 ; b < UltraCircuitBuilder::ExecutionTrace::NUM_BLOCKS; b++) {
1440+ size_t count = std::min (seq_blocks[b].size (), par_blocks[b].size ());
1441+ for (size_t w = 0 ; w < 4 ; w++) {
1442+ for (size_t i = 0 ; i < count; i++) {
1443+ if (seq_blocks[b].wires [w][i] != par_blocks[b].wires [w][i])
1444+ total_wire_diffs++;
1445+ }
1446+ }
1447+ }
1448+ info (" Wire-level differences (expected due to gate reordering): " , total_wire_diffs);
13501449}
0 commit comments