Skip to content

Commit 36dd37b

Browse files
crispasr integrationclaude
andcommitted
feat(pcs): PCS_DUMP_PRE / _SEG / _CAP — compare all four heads, not one
Mirrors CrispEmbed 381857ab into both copies of pcs.cpp here (the shared crisp_punc one and the src/ fallback), which test-copies-in-sync requires and verifies. PCS_DUMP_LOGITS covered post-punc only, so the pre, sentence-boundary and truecase heads could be checked solely through their effect on the restored string. Measured against a reference dumped from the model's own driving code (the punctuators package, via CrispEmbed's tools/dump_pcs_reference.py), plain q4_k gets all 67 post-punc decisions right and still turns "I'm OK" into "I'm ok" — visible, but with one head dumped, unattributable. With all four: post 67/67 pre 67/67 seg 67/67 cap 66/67 (token 37) One token, one head: q4_k leaves the punctuation path alone and the whole cost is the truecase head. Written in one block after every head has finished, so the dumps are aligned by construction rather than by three call sites agreeing about ordering. Append mode matches PCS_DUMP_LOGITS. PCS_DUMP_SEG deliberately has two columns — the 0.05-thresholded boundary (the ONNX seg_preds output) and the hard argmax that conditions the truecase head — because a bug in either produces wrong text and they disagree by design. PCS_DUMP_CAP is per CHARACTER of the token, ▁ included, not one flag per token. Verified: test-copies-in-sync 47/47, format.sh OK on 583 files, and CrispEmbed built against this copy scores 67/67 on every head. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent afe300d commit 36dd37b

2 files changed

Lines changed: 100 additions & 0 deletions

File tree

crisp_punc/src/pcs.cpp

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
#include <cstdio>
3737
#include <cstdlib>
3838
#include <cstring>
39+
#include <functional>
3940
#include <map>
4041
#include <string>
4142
#include <vector>
@@ -796,6 +797,55 @@ static PCSResult pcs_run(pcs_context& ctx, const std::vector<int>& token_ids) {
796797
}
797798
}
798799

800+
// Diff harness for the three heads PCS_DUMP_LOGITS does not reach.
801+
//
802+
// That hook covers post-punc only, so the pre / sentence-boundary / truecase
803+
// heads could previously be checked ONLY through their effect on the
804+
// restored string. That is a real gap and it showed: plain q4_k gets all 67
805+
// post-punc decisions right on the reference corpus and still turns
806+
// "I'm OK" into "I'm ok" — a truecase-head regression that surfaced as a text
807+
// diff, with nothing to say which head produced it.
808+
//
809+
// Written here, after every head has finished, so the four dumps are aligned
810+
// by construction rather than by three call sites agreeing about ordering.
811+
// Append mode matches PCS_DUMP_LOGITS — a consumer must delete the file
812+
// first, or a stale run silently shifts every comparison.
813+
//
814+
// ⚠ The seg head yields TWO different quantities and both are dumped,
815+
// because a bug in either produces wrong text and they disagree by design:
816+
// seg = softmax(logits)[boundary] > 0.05, the ONNX `seg_preds` output —
817+
// a low tuned threshold, NOT argmax
818+
// arg = the hard argmax, which is what conditions the truecase head
819+
// Dumping only one of them would make a mismatch in the other invisible.
820+
{
821+
auto dump = [&](const char* env, const std::function<void(FILE*, int)>& row) {
822+
const char* path = std::getenv(env);
823+
if (!path)
824+
return;
825+
FILE* fp = fopen(path, "a");
826+
if (!fp)
827+
return;
828+
for (int t = 0; t < N; t++) {
829+
row(fp, t);
830+
fputc('\n', fp);
831+
}
832+
fclose(fp);
833+
};
834+
// One int per token: the pre-punc argmax (0 = <NULL>, 1 = the inverted
835+
// Spanish opener).
836+
dump("PCS_DUMP_PRE", [&](FILE* fp, int t) { fprintf(fp, "%d", result.pre_preds[t]); });
837+
// Two ints per token: thresholded boundary, then the hard argmax.
838+
dump("PCS_DUMP_SEG",
839+
[&](FILE* fp, int t) { fprintf(fp, "%d %d", result.sbd_preds[t] ? 1 : 0, seg_argmax[t] ? 1 : 0); });
840+
// 16 bits per token, MSB-first by character position — the truecase head
841+
// is per-CHARACTER within the token, not one flag per token.
842+
dump("PCS_DUMP_CAP", [&](FILE* fp, int t) {
843+
for (size_t c = 0; c < result.cap_preds[t].size(); c++) {
844+
fputc(result.cap_preds[t][c] ? '1' : '0', fp);
845+
}
846+
});
847+
}
848+
799849
ggml_free(ctx0);
800850
return result;
801851
}

src/pcs.cpp

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
#include <cstdio>
3737
#include <cstdlib>
3838
#include <cstring>
39+
#include <functional>
3940
#include <map>
4041
#include <string>
4142
#include <vector>
@@ -796,6 +797,55 @@ static PCSResult pcs_run(pcs_context& ctx, const std::vector<int>& token_ids) {
796797
}
797798
}
798799

800+
// Diff harness for the three heads PCS_DUMP_LOGITS does not reach.
801+
//
802+
// That hook covers post-punc only, so the pre / sentence-boundary / truecase
803+
// heads could previously be checked ONLY through their effect on the
804+
// restored string. That is a real gap and it showed: plain q4_k gets all 67
805+
// post-punc decisions right on the reference corpus and still turns
806+
// "I'm OK" into "I'm ok" — a truecase-head regression that surfaced as a text
807+
// diff, with nothing to say which head produced it.
808+
//
809+
// Written here, after every head has finished, so the four dumps are aligned
810+
// by construction rather than by three call sites agreeing about ordering.
811+
// Append mode matches PCS_DUMP_LOGITS — a consumer must delete the file
812+
// first, or a stale run silently shifts every comparison.
813+
//
814+
// ⚠ The seg head yields TWO different quantities and both are dumped,
815+
// because a bug in either produces wrong text and they disagree by design:
816+
// seg = softmax(logits)[boundary] > 0.05, the ONNX `seg_preds` output —
817+
// a low tuned threshold, NOT argmax
818+
// arg = the hard argmax, which is what conditions the truecase head
819+
// Dumping only one of them would make a mismatch in the other invisible.
820+
{
821+
auto dump = [&](const char* env, const std::function<void(FILE*, int)>& row) {
822+
const char* path = std::getenv(env);
823+
if (!path)
824+
return;
825+
FILE* fp = fopen(path, "a");
826+
if (!fp)
827+
return;
828+
for (int t = 0; t < N; t++) {
829+
row(fp, t);
830+
fputc('\n', fp);
831+
}
832+
fclose(fp);
833+
};
834+
// One int per token: the pre-punc argmax (0 = <NULL>, 1 = the inverted
835+
// Spanish opener).
836+
dump("PCS_DUMP_PRE", [&](FILE* fp, int t) { fprintf(fp, "%d", result.pre_preds[t]); });
837+
// Two ints per token: thresholded boundary, then the hard argmax.
838+
dump("PCS_DUMP_SEG",
839+
[&](FILE* fp, int t) { fprintf(fp, "%d %d", result.sbd_preds[t] ? 1 : 0, seg_argmax[t] ? 1 : 0); });
840+
// 16 bits per token, MSB-first by character position — the truecase head
841+
// is per-CHARACTER within the token, not one flag per token.
842+
dump("PCS_DUMP_CAP", [&](FILE* fp, int t) {
843+
for (size_t c = 0; c < result.cap_preds[t].size(); c++) {
844+
fputc(result.cap_preds[t][c] ? '1' : '0', fp);
845+
}
846+
});
847+
}
848+
799849
ggml_free(ctx0);
800850
return result;
801851
}

0 commit comments

Comments
 (0)