Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"input": "grade_level",
"output": "rubric",
"source_path": "rubric-grade-3.txt",
"sha256": "3ddacd7b3c80f1f8e85bfcddd01db945813fcf814d893e719f5ae48babdaf11f",
"sha256": "d2522ad29cf7a1edc4288a13d5509001c64776ed38242b9a7b790d621fa978a8",
"condition": { "input": "grade_level", "in": ["3"] },
"implementation": {
"python": {
Expand All @@ -59,7 +59,7 @@
"input": "grade_level",
"output": "rubric",
"source_path": "rubric-grade-4.txt",
"sha256": "851c6afa4cd8a0ff0f599a38e96c96516dc2cccf0a4a7e945c982efebc89b6b7",
"sha256": "563ee744e2fe295ed1b8e5bcde00b7dfc89aac55730ed01088ed02b2a8175fd2",
"condition": { "input": "grade_level", "in": ["4"] },
"implementation": {
"python": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@
" try:\n",
" analysis = run_sentence_analysis(text)\n",
" features = add_engineered_features(analysis[\"formatted_output\"])\n",
" features_json = features_to_json(features)\n",
" features_json = features_to_json(features, cast_to_int=False)\n",
" classification = run_classify_complexity(features_json, grade_level, text)\n",
" return {\n",
" \"analysis\": analysis,\n",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,21 @@
* **Description:** The text consists of simple, straightforward language and sentence structures.
* **Statistical Guidelines:** The text is likely "Slightly Complex" if it meets at least TWO of the following criteria:
* **Sentence Type:** Primarily simple sentences. (`percent_simple_sentences` is typically > 60%).
* **Sentence Length:** Short sentences. (`avg_sentence_length` is typically < 12 words).
* **Sentence Length:** Short sentences. (`avg_words_per_sentence` is typically < 12 words).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@slee430 - To confirm, the code was already sending avg_words_per_sentence, but the rubric was referencing avg_sentence_length. The model was inferring on this own, and silently resolving this bug?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adnanrhussain Yes, the code was already sending avg_words_per_sentence, but the grade 3 and 4 rubrics referenced avg_sentence_length. The model may have inferred the connection, but we cannot confirm that it did so consistently. This fix makes the reference explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding your alternative approach, I’ll keep the current implementation for comparison and test your proposal in a separate branch across Python, TypeScript, and the notebook. I’ll keep the feature-name fix in both versions and make the rubric boundary changes explicit.
Then I’ll rerun the tests and compare the baseline, the current PR, and your proposal using the same extracted features. I’ll check the grade 4 results closely, since accuracy decreased there in the previous comparison. We can review the results before deciding which approach to use in this PR.

* **Subordination:** Very low use of clauses. (`percent_sentences_with_subordinate` is typically < 25%).

**Moderately Complex:**
* **Description:** The text shows a mix of simple and more complex sentences, introducing some variety in structure without being overly demanding.
* **Statistical Guidelines:** If the text is not "Slightly Complex", consider "Moderately Complex" if it generally aligns with these ranges:
* **Sentence Type:** A balanced mix of sentence types. (`percent_simple_sentences` is typically between 40% and 60%).
* **Sentence Length:** Medium length sentences. (`avg_sentence_length` is typically between 12 and 16 words).
* **Sentence Length:** Medium length sentences. (`avg_words_per_sentence` is typically between 12 and 16 words).
* **Subordination:** A moderate use of clauses. (`percent_sentences_with_subordinate` is typically between 25% and 45%).

**Very Complex:**
* **Description:** The text features more elaborate sentences with multiple clauses and ideas, requiring more effort from the reader to parse. This is often the default category for grade-level text that isn't simple or exceptionally difficult.
* **Statistical Guidelines:** If the text is more complex than "Moderately" but does not meet the "Exceedingly" criteria, it is likely "Very Complex". Key indicators include:
* **Sentence Type:** Complex structures are common. (`percent_simple_sentences` is a minority, typically < 40%).
* **Sentence Length:** Longer sentences are frequent. (`avg_sentence_length` is typically between 16 and 19 words).
* **Sentence Length:** Longer sentences are frequent. (`avg_words_per_sentence` is typically between 16 and 19 words).
* **Subordination:** Subordinate clauses are a key feature. (`percent_sentences_with_subordinate` is typically > 45%).

**Exceedingly Complex:**
Expand All @@ -30,6 +30,6 @@
* Multiple Subordinates: `percent_sentences_with_multiple_subordinates` is consistently present (typically > 12%).
* High Syntactic Complexity: `percent_compound_complex_sentences` is significant (typically > 15%).
* **Length Indicators:**
* Extreme Sentence Length: `avg_sentence_length` is very long (typically > 19 words).
* Extreme Sentence Length: `avg_words_per_sentence` is very long (typically > 19 words).
* Low Simplicity: `percent_simple_sentences` is very low (typically < 30%).
* Concentrated Length: `percent_very_long_sentences` is notable (typically > 10%).
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,21 @@
* **Description:** The text uses clear, direct language with basic sentence structures appropriate for developing readers.
* **Statistical Guidelines:** The text is likely "Slightly Complex" if it meets at least TWO of the following criteria:
* **Sentence Type:** Dominated by simple sentences. (`percent_simple_sentences` is typically > 55%).
* **Sentence Length:** Short to medium sentences. (`avg_sentence_length` is typically < 13 words).
* **Sentence Length:** Short to medium sentences. (`avg_words_per_sentence` is typically < 13 words).
* **Subordination:** Infrequent use of clauses. (`percent_sentences_with_subordinate` is typically < 30%).

**Moderately Complex:**
* **Description:** The text contains a variety of sentence structures, including compound and complex sentences, but remains accessible.
* **Statistical Guidelines:** If the text is not "Slightly Complex", consider "Moderately Complex" if it generally aligns with these ranges:
* **Sentence Type:** A healthy mix of sentence types. (`percent_simple_sentences` is typically between 40% and 55%).
* **Sentence Length:** Medium length sentences. (`avg_sentence_length` is typically between 13 and 17 words).
* **Sentence Length:** Medium length sentences. (`avg_words_per_sentence` is typically between 13 and 17 words).
* **Subordination:** A moderate number of clauses. (`percent_sentences_with_subordinate` is typically between 30% and 50%).

**Very Complex:**
* **Description:** The text is characterized by longer sentences and the regular use of dependent clauses, requiring readers to track multiple ideas. This is the default for challenging, on-grade-level texts.
* **Statistical Guidelines:** If the text is more complex than "Moderately" but does not meet the "Exceedingly" criteria, it is likely "Very Complex". Key indicators include:
* **Sentence Type:** Simple sentences are a clear minority. (`percent_simple_sentences` is typically < 40%).
* **Sentence Length:** Sentences are consistently long. (`avg_sentence_length` is typically between 17 and 22 words).
* **Sentence Length:** Sentences are consistently long. (`avg_words_per_sentence` is typically between 17 and 22 words).
* **Subordination:** Subordination is a major feature. (`percent_sentences_with_subordinate` is typically > 50%).
* **Multiple Subordination:** Sentences with multiple clauses appear more often. (`percent_sentences_with_multiple_subordinates` is typically > 8%).

Expand All @@ -31,6 +31,6 @@
* Multiple Subordinates: `percent_sentences_with_multiple_subordinates` is high and consistent (typically > 15%).
* High Syntactic Complexity: `percent_compound_complex_sentences` is a notable feature (typically > 20%).
* **Length Indicators:**
* Extreme Sentence Length: `avg_sentence_length` is exceptionally long (typically > 22 words).
* Extreme Sentence Length: `avg_words_per_sentence` is exceptionally long (typically > 22 words).
* Low Simplicity: `percent_simple_sentences` is very low (typically < 25%).
* Concentrated Length: `percent_very_long_sentences` is significant (typically > 15%).

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def sentence_features(analysis: Any) -> str:
Takes the first step's output, which the contract names as this entry's ``input``.
"""
counts = analysis.model_dump() if isinstance(analysis, BaseModel) else dict(analysis)
return features_to_json(add_engineered_features(counts))
return features_to_json(add_engineered_features(counts), cast_to_int=False)


class SentenceStructureEvaluator(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
arithmetic, so the contract declares it as preprocessing (``sentence_features``) rather
than asking a model for it, and this module is that computation.

Every derived value is rounded to one place here and cast to a whole number on the way into
the prompt, which is the shape the rubric's thresholds are written against.
Every derived value is rounded to one place here. The classifier preserves that fractional
place so per-sentence averages and phrase densities below one remain meaningful.
"""

from __future__ import annotations
Expand Down Expand Up @@ -196,36 +196,28 @@ def add_engineered_features(analysis: Mapping[str, Any]) -> dict[str, Any]:
_ratio(_count(analysis, "num_sophisticated_transitions"), transitions) * 100
)

# Deliberately Python's ties-to-even ``round``, not the SDK's half-up ``round_half_up``.
# These ratios are rationals over small counts, so they land on exact ties often, and a
# tie at ``n.95`` crosses an integer boundary once ``features_to_json`` casts it: 19
# transitions over 20 sentences is 0.95, which reaches the prompt as 0 under this rule
# and as 1 under the other. This is the arithmetic the notebook and the fixtures were
# produced by; unifying it is DSCR-2223, decided once for both SDKs alongside the
# truncate-versus-round question it belongs with.
# Preserve the notebook's ties-to-even rounding; cross-SDK rounding alignment
# is tracked separately in DSCR-2223.
features.update({name: round(value, PRECISION) for name, value in derived.items()})
return features


def features_to_json(features: Mapping[str, Any]) -> str:
def features_to_json(features: Mapping[str, Any], *, cast_to_int: bool = True) -> str:
""":data:`FEATURE_COLS` as the JSON object bound to ``{sentence_features}``.

Every value is a whole number: the rubric's thresholds are written in whole percentages
and averages, and the fractional place the model would otherwise read invites precision
it cannot support. A feature the analysis did not produce is sent as ``null`` rather
than zero, so a missing count reads as missing rather than as "none found".
Set ``cast_to_int=False`` to retain one decimal place for the classifier, including
averages and densities below one. The default preserves the legacy serializer API.
A feature the analysis did not produce is sent as ``null`` rather than zero.
"""
payload: dict[str, int | None] = {}
payload: dict[str, int | float | None] = {}
for name in FEATURE_COLS:
value = features.get(name)
if isinstance(value, bool) or not isinstance(value, (int, float)):
payload[name] = None
elif isinstance(value, int):
payload[name] = value
else:
# Truncated, not rounded: this is the notebook's arithmetic, and the fixtures
# and thresholds this evaluator was validated against were produced by it.
payload[name] = int(value)
payload[name] = int(value) if cast_to_int else round(value, PRECISION)
return json.dumps(payload, indent=2)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
"num_sentences": 2,
"num_words": 20,
"sentence_word_counts": [6, 14],
"num_subordinate_clauses": 1,
"num_simple_transitions": 1,
"num_sophisticated_transitions": 0,
}
)

Expand Down Expand Up @@ -91,10 +94,12 @@ async def test_the_classification_step_reads_the_features_derived_from_the_analy
) -> None:
await SentenceStructureEvaluator(openai_api_key="o").evaluate(text=TEXT, grade_level=5)
user = providers.calls[1]["messages"][1]["content"]
expected = features_to_json(add_engineered_features(ANALYSIS.model_dump()))
expected = features_to_json(add_engineered_features(ANALYSIS.model_dump()), cast_to_int=False)
assert expected in user
# Derived, not passed through: 20 words over 2 sentences, truncated for the prompt.
# Derived, not passed through: 20 words over 2 sentences.
assert json.loads(expected)["avg_words_per_sentence"] == 10
assert json.loads(expected)["avg_subordinates_per_sentence"] == 0.5
assert json.loads(expected)["avg_transitions_per_sentence"] == 0.5
assert TEXT in user


Expand All @@ -109,6 +114,9 @@ async def test_the_rubric_for_the_grade_is_the_one_the_contract_names(
rubric = SentenceStructureEvaluator.contract.document(marker)
user = providers.calls[1]["messages"][1]["content"]
assert f"# GRADE {grade} RUBRIC\n{rubric}" in user
assert "avg_sentence_length" not in user
if grade in (3, 4):
assert rubric.count("avg_words_per_sentence") == 4


async def test_returns_the_classification_as_the_result(providers: ProviderFactory) -> None:
Expand Down
Loading
Loading