Skip to content

Commit 38ca506

Browse files
authored
Merge branch 'master' into consensus-to-ms1feature
2 parents b620cb3 + 125c995 commit 38ca506

2 files changed

Lines changed: 182 additions & 2 deletions

File tree

mzLib/Readers/SpectralLibrary/MslWriter.cs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ public static void WriteStreaming(
364364
ModifiedSeqStringIdx = modSeqIdx,
365365
StrippedSeqStringIdx = stripSeqIdx,
366366
ProteinIdx = proteinSlotIndex.TryGetValue(acc, out int pIdx) ? pIdx : -1,
367-
FragmentCount = (short)frags.Count,
367+
FragmentCount = ToFragmentCount(frags.Count, entry.FullSequence),
368368
FragmentBlockOffset = fragOffset,
369369

370370
// Entry scalar fields — copied directly from the entry object.
@@ -813,6 +813,28 @@ public static long EstimateFileSize(int nPrecursors, int avgFragmentsPerPrecurso
813813
/// Returns a list of human-readable error strings. An empty list means all entries
814814
/// are safe to pass to <see cref="Write"/>.
815815
/// </summary>
816+
/// <summary>
817+
/// Maximum number of fragment ions a single entry may hold, bounded by the on-disk
818+
/// <see cref="MslPrecursorRecord.FragmentCount"/> field (a signed 16-bit int).
819+
/// </summary>
820+
internal const int MaxFragmentsPerEntry = short.MaxValue; // 32,767
821+
822+
/// <summary>
823+
/// Safely narrows a fragment count to the on-disk int16 field, throwing a clear, actionable
824+
/// error instead of silently wrapping to a negative value. A wrapped (negative) count produced
825+
/// a file that threw an OverflowException only later, on read; this fails fast on write.
826+
/// </summary>
827+
private static short ToFragmentCount(int count, string fullSequence)
828+
{
829+
if (count > MaxFragmentsPerEntry)
830+
throw new ArgumentException(
831+
$"Entry '{fullSequence}' has {count} fragment ions, which exceeds the .msl " +
832+
$"per-entry limit of {MaxFragmentsPerEntry} (FragmentCount is a 16-bit field in " +
833+
$"MslPrecursorRecord). Reduce the fragment count for this entry, or widen the " +
834+
$"format's FragmentCount field (a versioned format change).");
835+
return (short)count;
836+
}
837+
816838
public static List<string> ValidateEntries(IReadOnlyList<MslLibraryEntry> entries)
817839
{
818840
if (entries is null) throw new ArgumentNullException(nameof(entries));
@@ -967,7 +989,7 @@ private static void WritePrecursorArray(BinaryWriter writer, MslWriteLayout layo
967989
Irt = (float)entry.RetentionTime,
968990
IonMobility = (float)entry.IonMobility,
969991
Charge = (short)entry.ChargeState,
970-
FragmentCount = (short)entry.MatchedFragmentIons.Count,
992+
FragmentCount = ToFragmentCount(entry.MatchedFragmentIons.Count, entry.FullSequence),
971993
ElutionGroupId = entry.ElutionGroupId,
972994
ProteinIdx = pl.ProteinIdx,
973995
ModifiedSeqStringIdx = pl.ModifiedSeqStringIdx,

mzLib/Test/FileReadingTests/SpectralLibraryTests/MSL/TestMslWriter.cs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,4 +1287,162 @@ public void WriteFromLibrarySpectra_PreservesFragmentMzValues()
12871287
Assert.That(storedMz, Is.EqualTo((float)TargetMz).Within(1e-3f),
12881288
"Fragment m/z must be preserved to within float32 precision through the conversion path.");
12891289
}
1290+
1291+
// ────────────────────────────────────────────────────────────────────────
1292+
// Group: Fragment-count limit (PR #1079 — reject >short.MaxValue on write)
1293+
// ────────────────────────────────────────────────────────────────────────
1294+
//
1295+
// MslPrecursorRecord.FragmentCount is a signed 16-bit field, so an entry may
1296+
// hold at most short.MaxValue (32,767) fragment ions. Before this guard, a
1297+
// larger count was silently narrowed by an unchecked (short) cast: it wrapped
1298+
// to a negative value on write and only surfaced as an OverflowException
1299+
// (negative array length) later, on read. The guard moves that failure to
1300+
// write time, at the cause, on BOTH the in-memory (Write) and streaming
1301+
// (WriteStreaming) paths. These tests pin:
1302+
// • the over-limit case throws ArgumentException naming the entry + limit, and
1303+
// • the exact 32,767 boundary still writes unchanged (locks '>' vs '>=').
1304+
1305+
/// <summary>The per-entry fragment-ion limit imposed by the 16-bit on-disk field.</summary>
1306+
private const int MaxFragmentsPerEntry = short.MaxValue; // 32,767
1307+
1308+
/// <summary>
1309+
/// Builds a single entry holding exactly <paramref name="fragmentCount"/> fragment ions,
1310+
/// used to exercise the write-time fragment-count guard at and beyond the int16 limit.
1311+
/// Fragment scalar fields (FragmentNumber, ResiduePosition) are kept within int16 range so
1312+
/// the only field able to overflow is FragmentCount itself — isolating the behaviour under test.
1313+
/// </summary>
1314+
/// <param name="fragmentCount">Number of fragment ions to attach to the entry.</param>
1315+
/// <param name="fullSequence">FullSequence used to identify the entry in the thrown message.</param>
1316+
/// <returns>A list containing exactly one entry with the requested fragment count.</returns>
1317+
private static List<MslLibraryEntry> BuildSingleEntryWithFragmentCount(int fragmentCount, string fullSequence)
1318+
{
1319+
var fragments = new List<MslFragmentIon>(fragmentCount);
1320+
for (int f = 0; f < fragmentCount; f++)
1321+
{
1322+
fragments.Add(new MslFragmentIon
1323+
{
1324+
Mz = 100f + f * 0.01f,
1325+
Intensity = 1000f + (f % 500),
1326+
ProductType = ProductType.y,
1327+
SecondaryProductType = null,
1328+
FragmentNumber = (f % 1000) + 1, // stays within int16 range
1329+
SecondaryFragmentNumber = 0,
1330+
ResiduePosition = (f % 250) + 1,
1331+
Charge = 1,
1332+
NeutralLoss = 0.0,
1333+
ExcludeFromQuant = false
1334+
});
1335+
}
1336+
1337+
return new List<MslLibraryEntry>
1338+
{
1339+
new MslLibraryEntry
1340+
{
1341+
FullSequence = fullSequence,
1342+
BaseSequence = "PEPTIDE",
1343+
PrecursorMz = 449.7,
1344+
ChargeState = 2,
1345+
Source = MslFormat.SourceType.Predicted,
1346+
MoleculeType = MslFormat.MoleculeType.Peptide,
1347+
DissociationType = DissociationType.HCD,
1348+
MatchedFragmentIons = fragments
1349+
}
1350+
};
1351+
}
1352+
1353+
/// <summary>
1354+
/// In-memory path: an entry with exactly short.MaxValue (32,767) fragments must still write,
1355+
/// and the on-disk FragmentCount field must store 32,767 exactly (not a wrapped value). This
1356+
/// locks the guard's comparison as '&gt;' rather than '&gt;=' — i.e. the largest legal entry is
1357+
/// accepted, not rejected.
1358+
/// </summary>
1359+
[Test]
1360+
public void Write_AtMaxFragments_WritesAndStoresExactCount()
1361+
{
1362+
var entries = BuildSingleEntryWithFragmentCount(MaxFragmentsPerEntry, "MAXFRAGS_PEPTIDE");
1363+
string path = TempPath(nameof(Write_AtMaxFragments_WritesAndStoresExactCount));
1364+
1365+
Assert.That(() => MslWriter.Write(path, entries), Throws.Nothing,
1366+
"An entry with exactly 32,767 fragments is at the limit and must write successfully.");
1367+
1368+
byte[] data = ReadAllBytes(path);
1369+
long precursorSectionOffset = ReadInt64LE(data, HdrOffPrecursorSectionOffset);
1370+
1371+
// MslPrecursorRecord.FragmentCount is at offset 14 from the start of the record (int16)
1372+
const int FragCountOffsetInRecord = 14;
1373+
short storedFragCount = (short)(
1374+
data[(int)precursorSectionOffset + FragCountOffsetInRecord]
1375+
| (data[(int)precursorSectionOffset + FragCountOffsetInRecord + 1] << 8));
1376+
1377+
Assert.That(storedFragCount, Is.EqualTo((short)MaxFragmentsPerEntry),
1378+
"At the 32,767 boundary the on-disk FragmentCount must equal 32,767 (no overflow wrap).");
1379+
}
1380+
1381+
/// <summary>
1382+
/// In-memory path: an entry one past the limit (32,768 fragments) must be rejected on write with
1383+
/// an ArgumentException whose message names the offending entry and states the 32,767 limit, rather
1384+
/// than silently wrapping to a negative count that only fails later on read.
1385+
/// </summary>
1386+
[Test]
1387+
public void Write_ExceedingMaxFragments_ThrowsArgumentExceptionNamingEntryAndLimit()
1388+
{
1389+
const string Seq = "OVERLIMIT_PEPTIDE";
1390+
var entries = BuildSingleEntryWithFragmentCount(MaxFragmentsPerEntry + 1, Seq);
1391+
string path = TempPath(nameof(Write_ExceedingMaxFragments_ThrowsArgumentExceptionNamingEntryAndLimit));
1392+
1393+
var ex = Assert.Throws<ArgumentException>(() => MslWriter.Write(path, entries),
1394+
"An entry with 32,768 fragments exceeds the int16 field and must throw on the in-memory write path.");
1395+
1396+
Assert.That(ex.Message, Does.Contain("32767"),
1397+
"The exception message must state the per-entry limit (32,767).");
1398+
Assert.That(ex.Message, Does.Contain(Seq),
1399+
"The exception message must name the offending entry by its FullSequence.");
1400+
}
1401+
1402+
/// <summary>
1403+
/// Streaming path: the same guard must reject a 32,768-fragment entry, with the message naming the
1404+
/// entry and the 32,767 limit. The scope requires the guard on BOTH write paths; without this test a
1405+
/// regression that restored the unchecked (short) cast on only the streaming path would pass undetected.
1406+
/// </summary>
1407+
[Test]
1408+
public void WriteStreaming_ExceedingMaxFragments_ThrowsArgumentExceptionNamingEntryAndLimit()
1409+
{
1410+
const string Seq = "OVERLIMIT_STREAMING_PEPTIDE";
1411+
var entries = BuildSingleEntryWithFragmentCount(MaxFragmentsPerEntry + 1, Seq);
1412+
string path = TempPath(nameof(WriteStreaming_ExceedingMaxFragments_ThrowsArgumentExceptionNamingEntryAndLimit));
1413+
1414+
var ex = Assert.Throws<ArgumentException>(() => MslWriter.WriteStreaming(path, entries),
1415+
"An entry with 32,768 fragments must throw on the streaming write path, not just the in-memory path.");
1416+
1417+
Assert.That(ex.Message, Does.Contain("32767"),
1418+
"The streaming-path exception message must state the per-entry limit (32,767).");
1419+
Assert.That(ex.Message, Does.Contain(Seq),
1420+
"The streaming-path exception message must name the offending entry by its FullSequence.");
1421+
}
1422+
1423+
/// <summary>
1424+
/// Streaming path: an entry with exactly 32,767 fragments must still write, and the streaming path
1425+
/// must store the same FragmentCount (32,767) as the in-memory path — confirming the two paths agree
1426+
/// at the boundary and neither wraps the count.
1427+
/// </summary>
1428+
[Test]
1429+
public void WriteStreaming_AtMaxFragments_WritesAndStoresExactCount()
1430+
{
1431+
var entries = BuildSingleEntryWithFragmentCount(MaxFragmentsPerEntry, "MAXFRAGS_STREAMING_PEPTIDE");
1432+
string path = TempPath(nameof(WriteStreaming_AtMaxFragments_WritesAndStoresExactCount));
1433+
1434+
Assert.That(() => MslWriter.WriteStreaming(path, entries), Throws.Nothing,
1435+
"An entry with exactly 32,767 fragments is at the limit and must stream successfully.");
1436+
1437+
byte[] data = ReadAllBytes(path);
1438+
long precursorSectionOffset = ReadInt64LE(data, HdrOffPrecursorSectionOffset);
1439+
1440+
const int FragCountOffsetInRecord = 14;
1441+
short storedFragCount = (short)(
1442+
data[(int)precursorSectionOffset + FragCountOffsetInRecord]
1443+
| (data[(int)precursorSectionOffset + FragCountOffsetInRecord + 1] << 8));
1444+
1445+
Assert.That(storedFragCount, Is.EqualTo((short)MaxFragmentsPerEntry),
1446+
"At the 32,767 boundary the streaming path must store FragmentCount = 32,767 (no overflow wrap).");
1447+
}
12901448
}

0 commit comments

Comments
 (0)