=begin pod

=head1 NAME

LLM::Classifiers::Emotions - 28-way text emotion classifier using Cohee's
quantized DistilBERT go-emotions model

=head1 SYNOPSIS

=begin code :lang<raku>

use LLM::Classifiers::Emotions;

my $clf = LLM::Classifiers::Emotions::Classifier.new;

say $clf.top("I'm so angry I could scream");
    # 'anger'

say $clf.top("I'm really curious how this works");
    # 'curiosity'

for $clf.classify("I love you more than anything")[^3] -> %r {
    say "{ %r<label> }: { %r<score>.fmt('%.3f') }";
}
    # love: 0.263
    # caring: 0.175
    # admiration: 0.116

=end code

=head1 STATUS

v0.1 — single-text inference only. Batch / multi-label modes are
deferred to later releases. See LIMITATIONS below.

Output is fully deterministic under the CPU execution provider —
bit-identical logits across runs for the same input on the same
ONNX Runtime version. Tests assert this invariant.

=head1 INSTALLATION

=begin code :lang<shell>

zef install LLM::Classifiers::Emotions

=end code

At install time, C<Build.rakumod> downloads three files
(tokenizer.json, config.json, and a 68 MB quantized ONNX model)
from the module's GitHub Release, verifies SHA256 against bundled
checksums, and stages them under C<$XDG_DATA_HOME/
LLM-Classifiers-Emotions/>. If the GitHub Release path fails (or
if you set C<LLM_EMOTIONS_FROM_HF=1>), it falls back to downloading
directly from Cohee's HuggingFace repo at a pinned revision via
C<HuggingFace::API>.

No system-wide changes are made. C<zef uninstall> leaves the staged
model alone — remove it manually if you want the ~70 MB back.

=head2 Environment variables

=begin table
LLM_EMOTIONS_BINARY_URL   | Override GitHub Release base URL
LLM_EMOTIONS_BINARY_ONLY=1 | Refuse HF fallback; fail if the primary path doesn't complete
LLM_EMOTIONS_FROM_HF=1    | Skip primary, go straight to HuggingFace (dev / debug)
LLM_EMOTIONS_CACHE_DIR    | Override download cache dir (default $XDG_CACHE_HOME)
LLM_EMOTIONS_DATA_DIR     | Override staged base dir (default $XDG_DATA_HOME)
LLM_EMOTIONS_MODEL_DIR    | (runtime) load model from this dir instead of the staged dir
=end table

=head1 API

=head2 LLM::Classifiers::Emotions::Classifier

=head3 new(:@providers = [CPU], :$model-dir = IO::Path, :$log-id = 'llm-emotions')

Load the tokenizer, model, and label map. C<@providers> is passed
through to C<ONNX::Native::Session>; defaults to CPU-only so output
is reproducible. C<$model-dir> overrides the XDG-staged lookup —
useful for dev iteration or pointing at an alternate go-emotions
variant.

Throws C<X::LLM::Classifiers::Emotions::ModelMissing> if the staged
dir isn't found and no override is supplied. Throws
C<X::LLM::Classifiers::Emotions::InvalidConfig> if C<config.json>
doesn't parse.

=head3 classify(Str:D $text --> List[Hash])

Classify one text. Returns a list of 28 Hash entries, sorted
descending by score, each with C<< label => Str >> and
C<< score => Num >>. Scores sum to 1.0 ± 1e-5 (softmax invariant).

Input longer than 512 tokens is truncated — DistilBERT's positional
embeddings are fixed at that length, and Cohee's tokenizer doesn't
truncate on its own.

=head3 top(Str:D $text, Numeric :$min-score --> Str)

Argmax label — the most-likely single emotion. With C<:$min-score>,
returns C<Str> (the type object / undefined) if the top score falls
below the threshold.

=head3 labels(--> List[Str])

The 28 go-emotions labels in id order:
C<admiration amusement anger annoyance approval caring confusion
curiosity desire disappointment disapproval disgust embarrassment
excitement fear gratitude grief joy love nervousness optimism pride
realization relief remorse sadness surprise neutral>.

=head3 raw-logits(Str:D $text --> List[Num])

Pre-softmax 28-element logit list, straight from the ONNX session.
Exposed for tests that want to assert bit-identical output as a
determinism check. Normal callers should use C<classify>.

=head3 dispose() / DESTROY

Release the underlying ONNX session. Idempotent.

=head1 MODEL

=begin item
B<Source:> C<Cohee/distilbert-base-uncased-go-emotions-onnx> on
HuggingFace
(L<https://huggingface.co/Cohee/distilbert-base-uncased-go-emotions-onnx>).
The same model SillyTavern uses for its "expressions" feature.
=end item

=begin item
B<Architecture:> DistilBERT-base-uncased (6 transformer layers, 66 M
parameters), fine-tuned for sequence classification on the
GoEmotions dataset.
=end item

=begin item
B<Quantization:> int8 dynamic quantization. ~68 MB on disk vs.
~268 MB for the full-precision variant. Trivial accuracy difference
for this task.
=end item

=begin item
B<Training data:> L<GoEmotions|https://aclanthology.org/2020.acl-main.372/>
(Demszky et al, 2020) — ~58k English Reddit comments labelled for
27 emotion categories + "neutral".
=end item

=begin item
B<Revision pin:> C<d22488bc83be87678f12eee8a3f65a65de94ef85>. The
C<BINARY_TAG> file at the dist root and C<resources/REVISION>
together pin the exact model weights shipped by this release.
Bump both together when updating.
=end item

=head1 DETERMINISM

Under the default CPU provider, ONNX Runtime uses MLAS (Microsoft's
matrix math library) for the inference hot path. MLAS is fully
deterministic across runs for the same input + same ORT version:
logits are bit-identical, softmax preserves that, and argmax is
stable.

B<CoreML divergence note:> if you pass C<:providers<coreml cpu>>
on macOS, CoreML may produce slightly different scores (at the
~1e-3 level) because some ops downcast to fp16 internally on the
Neural Engine. The I<argmax> label still agrees with CPU for
go-emotions across all inputs we've tested. Tests pin C<:cpu> so
they pass regardless of provider.

Upgrading ONNX Runtime (the C<ONNX::Native> BINARY_TAG) may shift
logits at the LSB level if MLAS gets a new kernel. Re-compute test
expectations after such an upgrade.

=head1 PLATFORM NOTES

=head2 macOS

Staged under C<~/.local/share/LLM-Classifiers-Emotions/>. First
load of the model is ~200ms on Apple Silicon; subsequent classify
calls are ~30ms for typical chat-length input.

=head2 Linux

Same staging path. Performance is comparable on contemporary x86_64
(MLAS's AVX2 kernels kick in), slightly slower on aarch64.

=head2 Windows

Not supported in v0.1 — C<ONNX::Native> itself doesn't ship Windows
binaries yet. Once that lands, this module should work without
change (the protocols and file formats are platform-neutral).

=head1 LIMITATIONS

=item Single-text inference only — no batch mode. Classifying
      thousands of strings takes thousands of session runs.
      Batching is a worthwhile ~2x speedup and is queued for v0.2.
=item Single-label output only. The underlying GoEmotions dataset
      permits multi-label annotations; this module's argmax discards
      that information.
=item Input beyond 512 tokens is silently truncated. Callers that
      care about long-document sentiment should summarise or chunk
      upstream.
=item English-only. GoEmotions is entirely English Reddit; the
      model has no non-English training signal and scores garbage
      on non-English input.
=item Training-set biases — Reddit 2020 comments skew US-centric,
      young, male, and more casual than written text generally.
      Performance on formal text or non-Reddit dialects is
      degraded.

=head1 AUTHOR

=item Matt Doughty

=head1 MODEL CREDIT

=item Etch - original idea for the module
=item Cohee — ONNX / quantized conversion of the original model
=item Demszky et al (Google Research, 2020) — GoEmotions dataset
=item HuggingFace — original DistilBERT base model

=head1 COPYRIGHT AND LICENSE

This Raku module is Copyright 2026 Matt Doughty, distributed under
the Artistic License 2.0.

The underlying model weights ship separately (pulled at install
time) and are subject to their own licenses: MIT per Cohee's repo,
inheriting from google/distilbert-base-uncased (Apache-2.0) and
the GoEmotions corpus (CC BY 4.0).

=end pod
