Viney Claude Sonnet 4.6 commited on
Commit
d10b366
Β·
1 Parent(s): 402936a

test: add SubtextRead + _detect_trend tests

Browse files

Adds 19 new test cases covering SubtextRead signal_type coercion (all 5
canonical values, 6 aliases, unknown fallback, uppercase fix), BriefOutput
between_the_lines field (empty default + round-trip), and _detect_trend
pure function (rising/falling/flat/short/empty edge cases).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. tests/test_schemas.py +403 -0
tests/test_schemas.py CHANGED
@@ -3,7 +3,9 @@ from pydantic import ValidationError
3
  from agent.schemas import (
4
  SourcedFact, BriefOutput, TrendPoint, ManagementCommentaryTopic,
5
  MDASection, CategorizedRisk, GuidancePoint,
 
6
  )
 
7
 
8
 
9
  def test_sourced_fact_valid():
@@ -155,6 +157,37 @@ def test_brief_output_has_management_commentary_field():
155
  assert "transcript_topics" not in BriefOutput.model_fields
156
 
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  # ── CategorizedRisk category normalization ───────────────────────────────────
159
 
160
  def _risk(**kw) -> CategorizedRisk:
@@ -310,3 +343,373 @@ def test_brief_output_tolerates_compound_source_in_analytical_tension():
310
  brief = _minimal_brief(analytical_tensions=[tension])
311
  assert brief.analytical_tensions[0].bullish_evidence.source == "10-Q"
312
  assert brief.analytical_tensions[0].bearish_evidence.source == "10-Q"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from agent.schemas import (
4
  SourcedFact, BriefOutput, TrendPoint, ManagementCommentaryTopic,
5
  MDASection, CategorizedRisk, GuidancePoint,
6
+ EarningsQualitySignal, AnalyticalTension, SectionSentiment, SubtextRead,
7
  )
8
+ from analysis.textdiff import _detect_trend
9
 
10
 
11
  def test_sourced_fact_valid():
 
157
  assert "transcript_topics" not in BriefOutput.model_fields
158
 
159
 
160
+ def test_brief_output_drops_news_commentary_mixed():
161
+ """BriefOutput drops news-sourced items but keeps valid ones (filing/transcript)."""
162
+ brief = _minimal_brief(
163
+ management_commentary=[
164
+ {"topic": "AI strategy", "summary": "Management outlined AI plans.", "source": "transcript",
165
+ "reliability": "MEDIUM", "evidence_snippet": "We are fully committed to AI integration."},
166
+ {"topic": "Breaking news", "summary": "CNBC reported earnings beat.", "source": "news",
167
+ "reliability": "LOW", "evidence_snippet": "CNBC reported strong results."},
168
+ {"topic": "Revenue guidance", "summary": "Guided 3-5% growth.", "source": "10-Q",
169
+ "reliability": "HIGH", "evidence_snippet": "We expect revenue growth of 3-5%."},
170
+ ]
171
+ )
172
+ assert len(brief.management_commentary) == 2
173
+ topics = [t.topic for t in brief.management_commentary]
174
+ assert "AI strategy" in topics
175
+ assert "Revenue guidance" in topics
176
+ assert "Breaking news" not in topics
177
+
178
+
179
+ def test_brief_output_all_news_commentary_yields_empty_list():
180
+ """BriefOutput validates even when all management_commentary items are news-sourced (MSFT regression)."""
181
+ brief = _minimal_brief(
182
+ management_commentary=[
183
+ {"topic": f"Item {i}", "summary": "News summary.", "source": "news",
184
+ "reliability": "LOW", "evidence_snippet": f"News snippet {i}."}
185
+ for i in range(5)
186
+ ]
187
+ )
188
+ assert brief.management_commentary == []
189
+
190
+
191
  # ── CategorizedRisk category normalization ───────────────────────────────────
192
 
193
  def _risk(**kw) -> CategorizedRisk:
 
343
  brief = _minimal_brief(analytical_tensions=[tension])
344
  assert brief.analytical_tensions[0].bullish_evidence.source == "10-Q"
345
  assert brief.analytical_tensions[0].bearish_evidence.source == "10-Q"
346
+
347
+
348
+ # ── EarningsQualitySignal.assessment ─────────────────────────────────────────
349
+
350
+ def _eqs(**kw) -> EarningsQualitySignal:
351
+ defaults = dict(
352
+ dimension="guidance_dynamics",
353
+ assessment="positive",
354
+ rationale="Guidance was raised.",
355
+ evidence=SourcedFact(text="x", source="10-Q", reliability="HIGH", evidence_snippet="x"),
356
+ )
357
+ return EarningsQualitySignal(**(defaults | kw))
358
+
359
+
360
+ def test_eqs_assessment_valid_passthrough():
361
+ assert _eqs(assessment="concerning").assessment == "concerning"
362
+ assert _eqs(assessment="positive").assessment == "positive"
363
+ assert _eqs(assessment="neutral").assessment == "neutral"
364
+
365
+
366
+ def test_eqs_assessment_coerces_negative_to_concerning(capsys):
367
+ s = _eqs(assessment="negative")
368
+ assert s.assessment == "concerning"
369
+ assert "[quality-assessment]" in capsys.readouterr().err
370
+
371
+
372
+ def test_eqs_assessment_coerces_bearish_to_concerning():
373
+ assert _eqs(assessment="bearish").assessment == "concerning"
374
+
375
+
376
+ def test_eqs_assessment_coerces_bullish_to_positive():
377
+ assert _eqs(assessment="bullish").assessment == "positive"
378
+
379
+
380
+ def test_eqs_assessment_coerces_mixed_to_neutral():
381
+ assert _eqs(assessment="mixed").assessment == "neutral"
382
+
383
+
384
+ def test_eqs_assessment_unknown_falls_back_to_neutral(capsys):
385
+ s = _eqs(assessment="unclear")
386
+ assert s.assessment == "neutral"
387
+ assert "[quality-assessment]" in capsys.readouterr().err
388
+
389
+
390
+ # ── EarningsQualitySignal.dimension ──────────────────────────────────────────
391
+
392
+ def test_eqs_dimension_valid_passthrough():
393
+ assert _eqs(dimension="consensus_beat_mix").dimension == "consensus_beat_mix"
394
+ assert _eqs(dimension="capital_allocation").dimension == "capital_allocation"
395
+
396
+
397
+ def test_eqs_dimension_coerces_guidance_synonym(capsys):
398
+ s = _eqs(dimension="guidance")
399
+ assert s.dimension == "guidance_dynamics"
400
+ assert "[quality-dimension]" in capsys.readouterr().err
401
+
402
+
403
+ def test_eqs_dimension_coerces_beat_mix_synonym():
404
+ assert _eqs(dimension="beat_mix").dimension == "consensus_beat_mix"
405
+
406
+
407
+ def test_eqs_dimension_coerces_narrative_synonym():
408
+ assert _eqs(dimension="tone").dimension == "narrative_vs_numbers"
409
+
410
+
411
+ def test_eqs_dimension_coerces_segment_synonym():
412
+ assert _eqs(dimension="segment").dimension == "segment_mix"
413
+
414
+
415
+ def test_eqs_dimension_coerces_capital_synonym():
416
+ assert _eqs(dimension="capex").dimension == "capital_allocation"
417
+
418
+
419
+ def test_eqs_dimension_unknown_still_raises():
420
+ """Unknown dimension passes through _normalize_dimension unchanged β†’ Literal rejects it."""
421
+ with pytest.raises(ValidationError):
422
+ _eqs(dimension="completely_unknown_dim")
423
+
424
+
425
+ # ── BriefOutput._sanitize_quality_signals ────────────────────────────────────
426
+
427
+ def test_brief_output_drops_unknown_dimension_keeps_valid(capsys):
428
+ """Unknown-dimension item is dropped at brief level; valid item is kept."""
429
+ sf = {"text": "x", "source": "10-Q", "reliability": "HIGH", "evidence_snippet": "x"}
430
+ brief = _minimal_brief(
431
+ earnings_quality_signals=[
432
+ {"dimension": "totally_unknown", "assessment": "positive", "rationale": "r", "evidence": sf},
433
+ {"dimension": "guidance_dynamics", "assessment": "neutral", "rationale": "r", "evidence": sf},
434
+ ]
435
+ )
436
+ assert len(brief.earnings_quality_signals) == 1
437
+ assert brief.earnings_quality_signals[0].dimension == "guidance_dynamics"
438
+ assert "[quality-dimension]" in capsys.readouterr().err
439
+
440
+
441
+ def test_brief_output_all_unknown_dimension_yields_empty_list():
442
+ """All items with unknown dimension β†’ empty list, brief still validates."""
443
+ sf = {"text": "x", "source": "10-Q", "reliability": "HIGH", "evidence_snippet": "x"}
444
+ brief = _minimal_brief(
445
+ earnings_quality_signals=[
446
+ {"dimension": "foo", "assessment": "positive", "rationale": "r", "evidence": sf},
447
+ {"dimension": "bar", "assessment": "neutral", "rationale": "r", "evidence": sf},
448
+ ]
449
+ )
450
+ assert brief.earnings_quality_signals == []
451
+
452
+
453
+ # ── AnalyticalTension.weight ──────────────────────────────────────────────────
454
+
455
+ def _tension(**kw) -> AnalyticalTension:
456
+ sf = SourcedFact(text="x", source="10-Q", reliability="HIGH", evidence_snippet="x")
457
+ defaults = dict(
458
+ headline="H", bullish_reading="B", bearish_reading="Be",
459
+ weight="watch", bullish_evidence=sf, bearish_evidence=sf,
460
+ )
461
+ return AnalyticalTension(**(defaults | kw))
462
+
463
+
464
+ def test_tension_weight_valid_passthrough():
465
+ assert _tension(weight="material").weight == "material"
466
+ assert _tension(weight="watch").weight == "watch"
467
+ assert _tension(weight="minor").weight == "minor"
468
+
469
+
470
+ def test_tension_weight_coerces_high_to_material(capsys):
471
+ t = _tension(weight="high")
472
+ assert t.weight == "material"
473
+ assert "[tension-weight]" in capsys.readouterr().err
474
+
475
+
476
+ def test_tension_weight_coerces_medium_to_watch():
477
+ assert _tension(weight="medium").weight == "watch"
478
+
479
+
480
+ def test_tension_weight_coerces_low_to_minor():
481
+ assert _tension(weight="low").weight == "minor"
482
+
483
+
484
+ def test_tension_weight_coerces_critical_to_material():
485
+ assert _tension(weight="critical").weight == "material"
486
+
487
+
488
+ def test_tension_weight_unknown_falls_back_to_watch(capsys):
489
+ t = _tension(weight="extreme")
490
+ assert t.weight == "watch"
491
+ assert "[tension-weight]" in capsys.readouterr().err
492
+
493
+
494
+ # ── GuidancePoint.verdict ─────────────────────────────────────────────────────
495
+
496
+ def _gp(**kw) -> GuidancePoint:
497
+ defaults = dict(period="Q1 2025", text="Revenue guided flat.", source="10-Q")
498
+ return GuidancePoint(**(defaults | kw))
499
+
500
+
501
+ def test_guidance_verdict_valid_passthrough():
502
+ assert _gp(verdict="beat").verdict == "beat"
503
+ assert _gp(verdict="in-line").verdict == "in-line"
504
+ assert _gp(verdict="missed").verdict == "missed"
505
+ assert _gp(verdict="pending").verdict == "pending"
506
+
507
+
508
+ def test_guidance_verdict_none_passthrough():
509
+ assert _gp(verdict=None).verdict is None
510
+
511
+
512
+ def test_guidance_verdict_coerces_miss_to_missed(capsys):
513
+ g = _gp(verdict="miss")
514
+ assert g.verdict == "missed"
515
+ assert "[guidance-verdict]" in capsys.readouterr().err
516
+
517
+
518
+ def test_guidance_verdict_coerces_inline_to_in_line():
519
+ assert _gp(verdict="inline").verdict == "in-line"
520
+
521
+
522
+ def test_guidance_verdict_coerces_met_to_in_line():
523
+ assert _gp(verdict="met").verdict == "in-line"
524
+
525
+
526
+ def test_guidance_verdict_coerces_above_to_beat():
527
+ assert _gp(verdict="above").verdict == "beat"
528
+
529
+
530
+ def test_guidance_verdict_unknown_becomes_none(capsys):
531
+ g = _gp(verdict="partial")
532
+ assert g.verdict is None
533
+ assert "[guidance-verdict]" in capsys.readouterr().err
534
+
535
+
536
+ # ── SectionSentiment.score ────────────────────────────────────────────────────
537
+
538
+ def _ss(**kw) -> SectionSentiment:
539
+ defaults = dict(rationale="Revenue beat.")
540
+ return SectionSentiment(**(defaults | kw))
541
+
542
+
543
+ def test_sentiment_score_valid_passthrough():
544
+ for s in (-2, -1, 0, 1, 2):
545
+ assert _ss(score=s).score == s
546
+
547
+
548
+ def test_sentiment_score_none_passthrough():
549
+ assert _ss(score=None).score is None
550
+
551
+
552
+ def test_sentiment_score_coerces_string_int(capsys):
553
+ s = _ss(score="1")
554
+ assert s.score == 1
555
+ assert "[sentiment-score]" in capsys.readouterr().err
556
+
557
+
558
+ def test_sentiment_score_coerces_positive_string():
559
+ assert _ss(score="+2").score == 2
560
+
561
+
562
+ def test_sentiment_score_coerces_float():
563
+ assert _ss(score=1.0).score == 1
564
+
565
+
566
+ def test_sentiment_score_clamps_out_of_range(capsys):
567
+ s = _ss(score=3)
568
+ assert s.score == 2
569
+ assert "[sentiment-score]" in capsys.readouterr().err
570
+
571
+
572
+ def test_sentiment_score_non_numeric_becomes_none(capsys):
573
+ s = _ss(score="abc")
574
+ assert s.score is None
575
+ assert "[sentiment-score]" in capsys.readouterr().err
576
+
577
+
578
+ # ── SectionSentiment.label ────────────────────────────────────────────────────
579
+
580
+ def test_sentiment_label_valid_passthrough():
581
+ for lbl in ("Strongly Bearish", "Bearish", "Neutral", "Bullish", "Strongly Bullish"):
582
+ assert _ss(label=lbl).label == lbl
583
+
584
+
585
+ def test_sentiment_label_none_passthrough():
586
+ assert _ss(label=None).label is None
587
+
588
+
589
+ def test_sentiment_label_coerces_positive_to_bullish(capsys):
590
+ s = _ss(label="positive")
591
+ assert s.label == "Bullish"
592
+ assert "[sentiment-label]" in capsys.readouterr().err
593
+
594
+
595
+ def test_sentiment_label_coerces_negative_to_bearish():
596
+ assert _ss(label="negative").label == "Bearish"
597
+
598
+
599
+ def test_sentiment_label_unknown_becomes_none(capsys):
600
+ s = _ss(label="meh")
601
+ assert s.label is None
602
+ assert "[sentiment-label]" in capsys.readouterr().err
603
+
604
+
605
+ # ── SubtextRead ──────────────────────────────────────────────────────────────
606
+
607
+ def _str(signal_type="language_drift", **kw) -> SubtextRead:
608
+ sf = SourcedFact(text="x", source="10-Q", reliability="HIGH", evidence_snippet="x")
609
+ defaults = dict(
610
+ observation="Management used 'we expect growth' instead of 'we expect strong growth'.",
611
+ reading="The softening qualifier signals reduced conviction in guidance.",
612
+ signal_type=signal_type,
613
+ implication="Watch for a guidance cut next quarter if this trend continues.",
614
+ evidence=sf,
615
+ )
616
+ defaults.update(kw)
617
+ return SubtextRead(**defaults)
618
+
619
+
620
+ def test_subtext_read_valid_signal_types():
621
+ for st in ("language_drift", "qa_evasion", "omission", "emphasis_shift", "accounting_quality"):
622
+ assert _str(signal_type=st).signal_type == st
623
+
624
+
625
+ def test_subtext_read_coerces_evasion_to_qa_evasion(capsys):
626
+ s = _str(signal_type="evasion")
627
+ assert s.signal_type == "qa_evasion"
628
+ assert "[signal-type]" in capsys.readouterr().err
629
+
630
+
631
+ def test_subtext_read_coerces_drift_to_language_drift():
632
+ assert _str(signal_type="drift").signal_type == "language_drift"
633
+
634
+
635
+ def test_subtext_read_coerces_kpi_dropped_to_emphasis_shift():
636
+ assert _str(signal_type="kpi_dropped").signal_type == "emphasis_shift"
637
+
638
+
639
+ def test_subtext_read_coerces_silence_to_omission():
640
+ assert _str(signal_type="silence").signal_type == "omission"
641
+
642
+
643
+ def test_subtext_read_coerces_accounting_quality_alias():
644
+ assert _str(signal_type="earnings_quality").signal_type == "accounting_quality"
645
+
646
+
647
+ def test_subtext_read_unknown_falls_back_to_language_drift(capsys):
648
+ s = _str(signal_type="completely_unknown_signal")
649
+ assert s.signal_type == "language_drift"
650
+ assert "[signal-type]" in capsys.readouterr().err
651
+
652
+
653
+ def test_subtext_read_uppercase_canonical_coerced(capsys):
654
+ s = _str(signal_type="Accounting_Quality")
655
+ assert s.signal_type == "accounting_quality"
656
+ # The .lower() fix means the canonical is recognised directly β€” no stderr expected
657
+ capsys.readouterr() # consume any output
658
+
659
+
660
+ def test_subtext_read_evidence_is_sourced_fact():
661
+ s = _str()
662
+ assert isinstance(s.evidence, SourcedFact)
663
+
664
+
665
+ def test_brief_output_between_the_lines_empty_by_default():
666
+ brief = _minimal_brief()
667
+ assert brief.between_the_lines == []
668
+
669
+
670
+ def test_brief_output_accepts_between_the_lines_list():
671
+ sf = {"text": "x", "source": "10-Q", "reliability": "HIGH", "evidence_snippet": "x"}
672
+ item = {
673
+ "observation": "Analyst asked about China; management pivoted.",
674
+ "reading": "Evasion signals China pricing under pressure.",
675
+ "signal_type": "qa_evasion",
676
+ "implication": "Watch for China revenue disclosure next quarter.",
677
+ "evidence": sf,
678
+ }
679
+ brief = _minimal_brief(between_the_lines=[item])
680
+ assert len(brief.between_the_lines) == 1
681
+ assert brief.between_the_lines[0].signal_type == "qa_evasion"
682
+
683
+
684
+ # ── _detect_trend ────────────────────────────────────────────────────────────
685
+
686
+ def test_detect_trend_rising_4():
687
+ assert _detect_trend([1, 3, 5, 8]) == "rising 4 quarters"
688
+
689
+
690
+ def test_detect_trend_falling_4():
691
+ assert _detect_trend([8, 5, 3, 1]) == "falling 4 quarters"
692
+
693
+
694
+ def test_detect_trend_rising_3_at_tail():
695
+ assert _detect_trend([1, 5, 2, 4, 6]) == "rising 3 quarters"
696
+
697
+
698
+ def test_detect_trend_flat_returns_none():
699
+ assert _detect_trend([1, 2, 2, 4]) is None
700
+
701
+
702
+ def test_detect_trend_too_short_returns_none():
703
+ assert _detect_trend([1, 3]) is None
704
+
705
+
706
+ def test_detect_trend_empty_returns_none():
707
+ assert _detect_trend([]) is None
708
+
709
+
710
+ def test_detect_trend_single_value_returns_none():
711
+ assert _detect_trend([5]) is None
712
+
713
+
714
+ def test_detect_trend_falling_3_at_tail():
715
+ assert _detect_trend([10, 2, 8, 6, 4]) == "falling 3 quarters"