From 58d15265368c8a4aafcd070c7e0ea307421cdc9f Mon Sep 17 00:00:00 2001 From: Cassie-2025 Date: Thu, 16 Jul 2026 22:43:12 +0000 Subject: [PATCH] Add stdDevPop, stdDevSamp window operator and setWindowFields stage tests Add compatibility tests for: - stdDevPop window operator (11 test files, 146 tests) - stdDevSamp window operator (11 test files, 146 tests) - setWindowFields stage error validation (5 test files, 33 tests) Coverage includes: documents/range/time-range frame modes, partitioning, sort order, numeric precision, non-numeric handling, NaN/Infinity behavior, field paths, pipeline stage interactions, and frame validation errors. All 325 tests verified passing. Signed-off-by: Cassie-2025 --- ..._setWindowFields_documents_frame_errors.py | 438 ++++++++++ .../test_setWindowFields_multi_output.py | 135 +++ ...test_setWindowFields_range_frame_errors.py | 358 ++++++++ ...setWindowFields_time_range_frame_errors.py | 71 ++ ..._window_stdDevPop_documents_mode_frames.py | 427 ++++++++++ .../test_window_stdDevPop_field_paths.py | 463 ++++++++++ ...t_window_stdDevPop_non_numeric_handling.py | 803 ++++++++++++++++++ ...test_window_stdDevPop_numeric_precision.py | 650 ++++++++++++++ .../test_window_stdDevPop_partitioning.py | 745 ++++++++++++++++ .../test_window_stdDevPop_pipeline_stages.py | 514 +++++++++++ ...test_window_stdDevPop_range_mode_frames.py | 758 +++++++++++++++++ .../stdDevPop/test_window_stdDevPop_sort.py | 261 ++++++ .../test_window_stdDevPop_special_floats.py | 263 ++++++ ...window_stdDevPop_time_range_mode_frames.py | 607 +++++++++++++ ...window_stdDevSamp_documents_mode_frames.py | 423 +++++++++ .../test_window_stdDevSamp_field_paths.py | 465 ++++++++++ ..._window_stdDevSamp_non_numeric_handling.py | 803 ++++++++++++++++++ ...est_window_stdDevSamp_numeric_precision.py | 654 ++++++++++++++ .../test_window_stdDevSamp_partitioning.py | 748 ++++++++++++++++ .../test_window_stdDevSamp_pipeline_stages.py | 518 +++++++++++ ...est_window_stdDevSamp_range_mode_frames.py | 788 +++++++++++++++++ .../stdDevSamp/test_window_stdDevSamp_sort.py | 275 ++++++ .../test_window_stdDevSamp_special_floats.py | 288 +++++++ ...indow_stdDevSamp_time_range_mode_frames.py | 644 ++++++++++++++ 24 files changed, 12099 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_documents_frame_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_multi_output.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_range_frame_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_time_range_frame_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_documents_mode_frames.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_field_paths.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_non_numeric_handling.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_numeric_precision.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_partitioning.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_pipeline_stages.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_range_mode_frames.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_sort.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_special_floats.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_time_range_mode_frames.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_documents_mode_frames.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_field_paths.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_non_numeric_handling.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_numeric_precision.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_partitioning.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_pipeline_stages.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_range_mode_frames.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_sort.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_special_floats.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_time_range_mode_frames.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_documents_frame_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_documents_frame_errors.py new file mode 100644 index 000000000..d314626bc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_documents_frame_errors.py @@ -0,0 +1,438 @@ +""" +Tests for $setWindowFields documents-mode window frame validation errors. + +These are operator-agnostic — frame spec validation happens before the +accumulator runs. Uses $sum as the simplest accumulator. + +Covers: null window value, window as non-object types, unknown keys in window +object, document-bound validation errors (typo strings, wrong-length arrays, +null bounds, boolean bounds, non-object window, lower > upper, fractional +bounds), unknown accumulator keys, and specifying both documents and range. +""" + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.executor import execute_command + + +def test_window_null_value(collection): + """window: null produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": None, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="window: null rejected") + + +def test_window_array_value(collection): + """window as array produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": [-1, 0], + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="window as array rejected") + + +def test_window_unknown_key(collection): + """Unknown key in window object produces error.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": [0, 1], "bogus": 123}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="unknown key in window object rejected") + + +def test_documents_bounds_null_lower(collection): + """Null as lower document bound produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": [None, 0]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="null lower bound rejected") + + +def test_documents_bounds_null_upper(collection): + """Null as upper document bound produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": [0, None]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="null upper bound rejected") + + +def test_documents_bounds_empty_array(collection): + """Empty bounds array produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": []}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="empty bounds array rejected") + + +def test_documents_bounds_three_elements(collection): + """Three-element bounds array produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": [-1, 0, 1]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="three-element bounds array rejected") + + +def test_documents_bounds_boolean(collection): + """Boolean as document bound produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": [True, 0]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="boolean document bound rejected") + + +def test_output_field_unknown_accumulator_key(collection): + """Unknown key alongside accumulator in output field produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": [-1, 0]}, + "bogus": 1, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="unknown key in output field spec rejected") + + +def test_bound_typo_string(collection): + """Invalid bound string (not 'current' or 'unbounded') produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": ["invalid", 0]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="invalid bound string rejected") + + +def test_bounds_wrong_length(collection): + """Bounds array with wrong length (1 element) produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": ["unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="bounds array wrong length rejected") + + +def test_window_not_object(collection): + """Window value that is a string produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": "invalid", + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="window not an object rejected") + + +def test_lower_exceeds_upper(collection): + """Document bounds with lower > upper produces error 5339900.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": [1, -1]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 5339900, msg="documents lower > upper rejected") + + +def test_fractional_document_bound(collection): + """Fractional document bound produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": [-1.5, 0]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="fractional document bound rejected") + + +def test_both_documents_and_range(collection): + """Specifying both documents and range produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"documents": [-1, 0], "range": [-1, 0]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="both documents and range rejected") diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_multi_output.py b/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_multi_output.py new file mode 100644 index 000000000..c2d89f90a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_multi_output.py @@ -0,0 +1,135 @@ +""" +Tests for $setWindowFields multi-output behavior. + +These are operator-agnostic — they test the stage's ability to handle +multiple output fields with different accumulators and window specs. + +Covers: multiple different operators in one output clause, same operator +with different window specs, and output field that overwrites _id. +""" + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_mixed_operators_multi_output(collection): + """Multiple different operators ($sum + $avg + $count) in one output clause.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "total": { + "$sum": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + }, + "avg": { + "$avg": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + }, + "cnt": { + "$count": {}, + "window": {"documents": ["unbounded", "unbounded"]}, + }, + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "total": 60, "avg": 20.0, "cnt": 3}, + {"_id": 2, "partition": "A", "value": 20, "total": 60, "avg": 20.0, "cnt": 3}, + {"_id": 3, "partition": "A", "value": 30, "total": 60, "avg": 20.0, "cnt": 3}, + ] + assertSuccess(result, expected, msg="mixed operators in one output clause") + + +def test_same_operator_different_windows(collection): + """Same operator ($sum) with different window specs in one output clause.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "cumulative": { + "$sum": "$value", + "window": {"documents": ["unbounded", "current"]}, + }, + "whole": { + "$sum": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + }, + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "cumulative": 10, "whole": 60}, + {"_id": 2, "partition": "A", "value": 20, "cumulative": 30, "whole": 60}, + {"_id": 3, "partition": "A", "value": 30, "cumulative": 60, "whole": 60}, + ] + assertSuccess(result, expected, msg="same operator with different windows in one output") + + +def test_output_field_name_id(collection): + """Output field named '_id' overwrites original document _id.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "_id": { + "$sum": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 60, "partition": "A", "value": 10}, + {"_id": 60, "partition": "A", "value": 20}, + {"_id": 60, "partition": "A", "value": 30}, + ] + assertSuccess(result, expected, msg="output field _id overwrites document _id") diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_range_frame_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_range_frame_errors.py new file mode 100644 index 000000000..c9af63e67 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_range_frame_errors.py @@ -0,0 +1,358 @@ +""" +Tests for $setWindowFields range-mode window frame validation errors. + +These are operator-agnostic — frame spec validation happens before the +accumulator runs. Uses $sum as the simplest accumulator. + +Covers: invalid bound types (non-numeric string, boolean, null), wrong-length +bounds arrays, null/missing sortBy values, no sortBy, descending sort, +multiple sortBy fields, lower > upper, and non-numeric sortBy values. +""" + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.executor import execute_command + + +def test_range_non_numeric_bound(collection): + """Range bound that is non-numeric (string, not 'unbounded'/'current') produces error.""" + docs = [{"_id": 1, "partition": "A", "score": 1, "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": ["invalid", 0]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="non-numeric, non-keyword range bound rejected") + + +def test_range_boolean_bound(collection): + """Range bound that is a boolean produces error.""" + docs = [{"_id": 1, "partition": "A", "score": 1, "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": [True, 10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="boolean range bound rejected") + + +def test_range_null_bound(collection): + """Range bound that is null produces error.""" + docs = [{"_id": 1, "partition": "A", "score": 1, "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": [None, 10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="null range bound rejected") + + +def test_range_empty_bounds_array(collection): + """Range with empty bounds array produces error.""" + docs = [{"_id": 1, "partition": "A", "score": 1, "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": []}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="empty range bounds array rejected") + + +def test_range_three_element_bounds(collection): + """Range with three-element bounds array produces error.""" + docs = [{"_id": 1, "partition": "A", "score": 1, "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": [-10, 0, 10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="three-element range bounds rejected") + + +def test_range_null_sortby_value(collection): + """Range mode with null sortBy value produces error 5429414.""" + docs = [ + {"_id": 1, "partition": "A", "score": None, "value": 10}, + {"_id": 2, "partition": "A", "score": 5, "value": 20}, + {"_id": 3, "partition": "A", "score": 10, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": [-10, 10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 5429414, msg="null sortBy value in range mode rejected") + + +def test_range_missing_sortby_field(collection): + """Range mode with missing sortBy field produces error 5429414.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "score": 5, "value": 20}, + {"_id": 3, "partition": "A", "score": 10, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": [-10, 10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 5429414, msg="missing sortBy field in range mode rejected") + + +def test_range_no_sortby(collection): + """Range window without sortBy produces error.""" + docs = [{"_id": 1, "partition": "A", "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "output": { + "result": { + "$sum": "$value", + "window": {"range": [-10, 10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 5339902, msg="range window without sortBy rejected") + + +def test_range_descending_sort(collection): + """Range mode with descending sort produces error 8947401.""" + docs = [{"_id": 1, "partition": "A", "score": 1, "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": -1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": [-10, 10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 8947401, msg="descending sort rejected in range mode") + + +def test_range_multiple_sortby(collection): + """Range mode with multiple sortBy fields produces error 5339902.""" + docs = [{"_id": 1, "partition": "A", "score": 1, "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1, "_id": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": [-10, 10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 5339902, msg="multiple sortBy rejected in range mode") + + +def test_range_lower_exceeds_upper(collection): + """Range bounds with lower > upper produces error 5339900.""" + docs = [{"_id": 1, "partition": "A", "score": 1, "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": [10, -10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 5339900, msg="lower > upper rejected in range mode") + + +def test_range_non_numeric_sortby_value(collection): + """Range mode with non-numeric sortBy value produces error 5429414.""" + docs = [ + {"_id": 1, "partition": "A", "score": "abc", "value": 10}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": [-10, 10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 5429414, msg="non-numeric sortBy value rejected in range mode") diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_time_range_frame_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_time_range_frame_errors.py new file mode 100644 index 000000000..caa280456 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/setWindowFields/test_setWindowFields_time_range_frame_errors.py @@ -0,0 +1,71 @@ +""" +Tests for $setWindowFields time-range mode frame validation errors. + +These are operator-agnostic — frame spec validation happens before the +accumulator runs. Uses $sum as the simplest accumulator. + +Covers: invalid time unit string, time unit with non-date sortBy value. +""" + +from datetime import datetime, timezone + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.executor import execute_command + + +def test_time_range_invalid_unit(collection): + """Range with invalid time unit produces error.""" + docs = [ + {"_id": 1, "partition": "A", "date": datetime(2023, 1, 1, tzinfo=timezone.utc), "value": 10} + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": [-1, 0], "unit": "invalid_unit"}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 9, msg="invalid time unit rejected") + + +def test_time_range_unit_without_date_sortby(collection): + """Range with time unit but non-date sortBy value produces error.""" + docs = [{"_id": 1, "partition": "A", "score": 10, "value": 10}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$sum": "$value", + "window": {"range": [-1, 0], "unit": "day"}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, 5429513, msg="time unit with non-date sortBy rejected") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_documents_mode_frames.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_documents_mode_frames.py new file mode 100644 index 000000000..27de9ea9a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_documents_mode_frames.py @@ -0,0 +1,427 @@ +""" +Tests for $stdDevPop with documents-mode window frames. + +Covers all frame shapes: cumulative, reverse-cumulative, sliding, centered, +leading, trailing, both-before, both-after, single-row, wider-than-partition, +and whole partition (unbounded-unbounded). +""" + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def _run_stdDevPop(collection, docs, window, sort_by=None, extra_stages=None): + """Helper to run a $stdDevPop pipeline with given window.""" + collection.insert_many(docs) + pipeline = [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": sort_by or {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": window, + } + }, + } + } + ] + if extra_stages: + pipeline.extend(extra_stages) + return execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + + +BASIC_DOCS = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + {"_id": 4, "partition": "A", "value": 40}, + {"_id": 5, "partition": "A", "value": 50}, +] + + +def test_stdDevPop_whole_partition(collection): + """$stdDevPop over unbounded-unbounded returns same value for all rows.""" + result = _run_stdDevPop(collection, BASIC_DOCS, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "value": 20, "result": 14.142135623730951}, + {"_id": 3, "partition": "A", "value": 30, "result": 14.142135623730951}, + {"_id": 4, "partition": "A", "value": 40, "result": 14.142135623730951}, + {"_id": 5, "partition": "A", "value": 50, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="whole partition stdDevPop should be sqrt(200)") + + +def test_stdDevPop_cumulative(collection): + """$stdDevPop cumulative window grows with each row.""" + result = _run_stdDevPop(collection, BASIC_DOCS, {"documents": ["unbounded", "current"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 0.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "value": 40, "result": 11.180339887498949}, + {"_id": 5, "partition": "A", "value": 50, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="cumulative stdDevPop should grow") + + +def test_stdDevPop_reverse_cumulative(collection): + """$stdDevPop reverse-cumulative window shrinks with each row.""" + result = _run_stdDevPop( + collection, + BASIC_DOCS, + {"documents": ["current", "unbounded"]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "value": 20, "result": 11.180339887498949}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "value": 40, "result": 5.0}, + {"_id": 5, "partition": "A", "value": 50, "result": 0}, + ] + assertSuccess(result, expected, msg="reverse-cumulative stdDevPop should shrink") + + +def test_stdDevPop_sliding_centered(collection): + """$stdDevPop centered sliding window [-1, 1].""" + result = _run_stdDevPop(collection, BASIC_DOCS, {"documents": [-1, 1]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "value": 40, "result": 8.16496580927726}, + {"_id": 5, "partition": "A", "value": 50, "result": 5.0}, + ] + assertSuccess(result, expected, msg="centered sliding window [-1,1]") + + +def test_stdDevPop_trailing(collection): + """$stdDevPop trailing window [-1, 0].""" + result = _run_stdDevPop( + collection, + BASIC_DOCS, + {"documents": [-1, 0]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 0}, + {"_id": 2, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 5.0}, + {"_id": 4, "partition": "A", "value": 40, "result": 5.0}, + {"_id": 5, "partition": "A", "value": 50, "result": 5.0}, + ] + assertSuccess(result, expected, msg="trailing window [-1,0]") + + +def test_stdDevPop_leading(collection): + """$stdDevPop leading window [0, 1].""" + result = _run_stdDevPop( + collection, + BASIC_DOCS, + {"documents": [0, 1]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 5.0}, + {"_id": 4, "partition": "A", "value": 40, "result": 5.0}, + {"_id": 5, "partition": "A", "value": 50, "result": 0}, + ] + assertSuccess(result, expected, msg="leading window [0,1]") + + +def test_stdDevPop_both_before(collection): + """$stdDevPop both-before window [-2, -1] with empty frame on first row.""" + result = _run_stdDevPop( + collection, + BASIC_DOCS, + {"documents": [-2, -1]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": None}, + {"_id": 2, "partition": "A", "value": 20, "result": 0}, + {"_id": 3, "partition": "A", "value": 30, "result": 5.0}, + {"_id": 4, "partition": "A", "value": 40, "result": 5.0}, + {"_id": 5, "partition": "A", "value": 50, "result": 5.0}, + ] + assertSuccess(result, expected, msg="both-before window [-2,-1]") + + +def test_stdDevPop_both_after(collection): + """$stdDevPop both-after window [1, 2] with empty frame on last row.""" + result = _run_stdDevPop( + collection, + BASIC_DOCS, + {"documents": [1, 2]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 5.0}, + {"_id": 4, "partition": "A", "value": 40, "result": 0}, + {"_id": 5, "partition": "A", "value": 50, "result": None}, + ] + assertSuccess(result, expected, msg="both-after window [1,2]") + + +def test_stdDevPop_wider_than_partition(collection): + """$stdDevPop window wider than partition [-10, 10] clamps to partition edges.""" + result = _run_stdDevPop(collection, BASIC_DOCS, {"documents": [-10, 10]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "value": 20, "result": 14.142135623730951}, + {"_id": 3, "partition": "A", "value": 30, "result": 14.142135623730951}, + {"_id": 4, "partition": "A", "value": 40, "result": 14.142135623730951}, + {"_id": 5, "partition": "A", "value": 50, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="wider-than-partition clamps to edges") + + +def test_stdDevPop_empty_frame_returns_null(collection): + """$stdDevPop on an empty frame (e.g. [-5, -4] on early rows) returns null.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + ] + result = _run_stdDevPop(collection, docs, {"documents": [-5, -4]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": None}, + {"_id": 2, "partition": "A", "value": 20, "result": None}, + ] + assertSuccess(result, expected, msg="empty frame returns null") + + +def test_stdDevPop_default_window_no_window_key(collection): + """$stdDevPop with no explicit window key defaults to whole partition.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": {"$stdDevPop": "$value"}, + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="no window key defaults to whole partition") + + +def test_stdDevPop_sliding_3_doc_window(collection): + """$stdDevPop sliding window [-2, 0] covering 3 docs.""" + result = _run_stdDevPop( + collection, + BASIC_DOCS, + {"documents": [-2, 0]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 0}, + {"_id": 2, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "value": 40, "result": 8.16496580927726}, + {"_id": 5, "partition": "A", "value": 50, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="sliding [-2,0] window") + + +def test_stdDevPop_mixed_unbounded_and_int(collection): + """$stdDevPop with mixed unbounded lower and integer upper ["unbounded", 1].""" + result = _run_stdDevPop(collection, BASIC_DOCS, {"documents": ["unbounded", 1]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": 30, "result": 11.180339887498949}, + {"_id": 4, "partition": "A", "value": 40, "result": 14.142135623730951}, + {"_id": 5, "partition": "A", "value": 50, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="mixed [unbounded, 1] window") + + +def test_stdDevPop_int_lower_to_current(collection): + """$stdDevPop with integer lower and 'current' upper [-5, "current"].""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stdDevPop( + collection, + docs, + {"documents": [-5, "current"]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 0}, + {"_id": 2, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="[-5, current] clamps to partition start") + + +def test_stdDevPop_two_docs_partition(collection): + """$stdDevPop with exactly 2 documents in partition.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 30}, + ] + result = _run_stdDevPop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # stdDevPop of [10, 30]: mean=20, variance=(100+100)/2=100, stddev=10 + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="two-doc partition computes correctly") + + +def test_stdDevPop_all_zeros(collection): + """$stdDevPop where all values are zero returns 0.""" + docs = [ + {"_id": 1, "partition": "A", "value": 0}, + {"_id": 2, "partition": "A", "value": 0}, + {"_id": 3, "partition": "A", "value": 0}, + ] + result = _run_stdDevPop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 0, "result": 0.0}, + {"_id": 2, "partition": "A", "value": 0, "result": 0.0}, + {"_id": 3, "partition": "A", "value": 0, "result": 0.0}, + ] + assertSuccess(result, expected, msg="all zeros return 0") + + +def test_stdDevPop_mix_positive_negative_zero(collection): + """$stdDevPop with positive, negative, and zero values.""" + docs = [ + {"_id": 1, "partition": "A", "value": -10}, + {"_id": 2, "partition": "A", "value": 0}, + {"_id": 3, "partition": "A", "value": 10}, + {"_id": 4, "partition": "A", "value": 0}, + ] + result = _run_stdDevPop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # Values: [-10, 0, 10, 0], mean=0, variance=(100+0+100+0)/4=50, stddev=sqrt(50)≈7.071 + expected = [ + {"_id": 1, "partition": "A", "value": -10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "value": 0, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": 10, "result": 7.0710678118654755}, + {"_id": 4, "partition": "A", "value": 0, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="mixed positive, negative, and zero") + + +def test_stdDevPop_forward_looking_sliding(collection): + """$stdDevPop forward-looking sliding window [1, 2].""" + result = _run_stdDevPop( + collection, + BASIC_DOCS, + {"documents": [1, 2]}, + ) + # Window [1, 2]: 1 and 2 rows ahead + # Row 1: [20, 30] → stdDevPop = 5.0 + # Row 2: [30, 40] → stdDevPop = 5.0 + # Row 3: [40, 50] → stdDevPop = 5.0 + # Row 4: [50] → single value → 0 + # Row 5: [] → empty → None + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 5.0}, + {"_id": 4, "partition": "A", "value": 40, "result": 0}, + {"_id": 5, "partition": "A", "value": 50, "result": None}, + ] + assertSuccess(result, expected, msg="forward-looking window [1,2] sliding") + + +def test_stddevpop_empty_window_object(collection): + """$stdDevPop with empty window object {} is equivalent to no window (whole partition).""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="empty window {} = whole partition") + + +def test_stddevpop_no_sortby_with_documents_window(collection): + """$stdDevPop with documents window but no sortBy — unbounded still works.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + ] + assertSuccess( + result, expected, ignore_doc_order=True, msg="no sortBy with documents unbounded window" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_field_paths.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_field_paths.py new file mode 100644 index 000000000..8dc331a91 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_field_paths.py @@ -0,0 +1,463 @@ +""" +Tests for $stdDevPop with nested field paths, array field traversal, +expressions that return different types per document, and $unset/$project +removing fields before $setWindowFields. + +Covers: dotted field paths, missing intermediate paths, array index access, +array-of-objects traversal, top-level array fields, expressions returning +mixed types per row, and pipeline stages removing sortBy/partitionBy/expression +fields. +""" + +from datetime import datetime, timezone + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_stddevpop_dotted_field_path(collection): + """$stdDevPop with dotted field path accesses nested document value.""" + docs = [ + {"_id": 1, "partition": "A", "data": {"metrics": {"value": 10}}}, + {"_id": 2, "partition": "A", "data": {"metrics": {"value": 20}}}, + {"_id": 3, "partition": "A", "data": {"metrics": {"value": 30}}}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$data.metrics.value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + { + "_id": 1, + "partition": "A", + "data": {"metrics": {"value": 10}}, + "result": 8.16496580927726, + }, + { + "_id": 2, + "partition": "A", + "data": {"metrics": {"value": 20}}, + "result": 8.16496580927726, + }, + { + "_id": 3, + "partition": "A", + "data": {"metrics": {"value": 30}}, + "result": 8.16496580927726, + }, + ] + assertSuccess(result, expected, msg="dotted field path accesses nested value") + + +def test_stddevpop_missing_intermediate_path(collection): + """$stdDevPop with missing intermediate path treated as missing (ignored).""" + docs = [ + {"_id": 1, "partition": "A", "data": {"metrics": {"value": 10}}}, + {"_id": 2, "partition": "A", "data": {"other": 99}}, + {"_id": 3, "partition": "A", "data": {"metrics": {"value": 30}}}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$data.metrics.value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Doc 2 missing data.metrics.value → ignored. stdDevPop of [10, 30] = 10.0 + expected = [ + {"_id": 1, "partition": "A", "data": {"metrics": {"value": 10}}, "result": 10.0}, + {"_id": 2, "partition": "A", "data": {"other": 99}, "result": 10.0}, + {"_id": 3, "partition": "A", "data": {"metrics": {"value": 30}}, "result": 10.0}, + ] + assertSuccess(result, expected, msg="missing intermediate path treated as missing") + + +def test_stddevpop_top_level_missing_object(collection): + """$stdDevPop where the top-level field of a dotted path is missing.""" + docs = [ + {"_id": 1, "partition": "A", "data": {"value": 10}}, + {"_id": 2, "partition": "A"}, + {"_id": 3, "partition": "A", "data": {"value": 30}}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$data.value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "data": {"value": 10}, "result": 10.0}, + {"_id": 2, "partition": "A", "result": 10.0}, + {"_id": 3, "partition": "A", "data": {"value": 30}, "result": 10.0}, + ] + assertSuccess(result, expected, msg="top-level field missing in dotted path = ignored") + + +def test_stddevpop_array_field_is_non_numeric(collection): + """$stdDevPop on a top-level array field — arrays are non-numeric, should be ignored.""" + docs = [ + {"_id": 1, "partition": "A", "value": [10, 20, 30]}, + {"_id": 2, "partition": "A", "value": 50}, + {"_id": 3, "partition": "A", "value": [40, 50]}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Only doc 2 has a numeric value. Single numeric value in whole partition → stdDevPop = 0.0 + expected = [ + {"_id": 1, "partition": "A", "value": [10, 20, 30], "result": 0.0}, + {"_id": 2, "partition": "A", "value": 50, "result": 0.0}, + {"_id": 3, "partition": "A", "value": [40, 50], "result": 0.0}, + ] + assertSuccess(result, expected, msg="array field values are non-numeric — ignored") + + +def test_stddevpop_expression_returns_different_types(collection): + """$stdDevPop expression returning different types per row — non-numeric ignored.""" + docs = [ + {"_id": 1, "partition": "A", "x": 10}, + {"_id": 2, "partition": "A", "x": -5}, + {"_id": 3, "partition": "A", "x": 30}, + {"_id": 4, "partition": "A", "x": -1}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": { + "$cond": [ + {"$gt": ["$x", 0]}, + "$x", + "not_a_number", + ] + }, + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # $cond returns: 10, "not_a_number", 30, "not_a_number" + # Only numeric: [10, 30] → stdDevPop = 10.0 + expected = [ + {"_id": 1, "partition": "A", "x": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "x": -5, "result": 10.0}, + {"_id": 3, "partition": "A", "x": 30, "result": 10.0}, + {"_id": 4, "partition": "A", "x": -1, "result": 10.0}, + ] + assertSuccess(result, expected, msg="expression returning mixed types — non-numeric ignored") + + +def test_stddevpop_expression_returns_null_for_some(collection): + """$stdDevPop expression returning null for some docs, number for others.""" + docs = [ + {"_id": 1, "partition": "A", "x": 10, "y": 2}, + {"_id": 2, "partition": "A", "x": 20, "y": None}, + {"_id": 3, "partition": "A", "x": 30, "y": 2}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": {"$multiply": ["$x", "$y"]}, + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # $multiply: [10*2=20, 20*null=null, 30*2=60] + # Numeric values: [20, 60] → stdDevPop = 20.0 + expected = [ + {"_id": 1, "partition": "A", "x": 10, "y": 2, "result": 20.0}, + {"_id": 2, "partition": "A", "x": 20, "y": None, "result": 20.0}, + {"_id": 3, "partition": "A", "x": 30, "y": 2, "result": 20.0}, + ] + assertSuccess(result, expected, msg="expression returning null for some — null results ignored") + + +def test_stddevpop_project_removes_expression_field(collection): + """$project removing the expression field before $setWindowFields — treated as missing.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10, "extra": 99}, + {"_id": 2, "partition": "A", "value": 20, "extra": 99}, + {"_id": 3, "partition": "A", "value": 30, "extra": 99}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$project": {"partition": 1, "extra": 1}}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # All docs missing "value" → all non-numeric → result is null + expected = [ + {"_id": 1, "partition": "A", "extra": 99, "result": None}, + {"_id": 2, "partition": "A", "extra": 99, "result": None}, + {"_id": 3, "partition": "A", "extra": 99, "result": None}, + ] + assertSuccess(result, expected, msg="$project removing expression field → all null results") + + +def test_stddevpop_unset_partitionby_field(collection): + """$unset removing partitionBy field — all docs share one partition (null key).""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "B", "value": 20}, + {"_id": 3, "partition": "C", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$unset": "partition"}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # All docs have missing partition → single partition → stdDevPop of [10,20,30] = 8.165 + expected = [ + {"_id": 1, "value": 10, "result": 8.16496580927726}, + {"_id": 2, "value": 20, "result": 8.16496580927726}, + {"_id": 3, "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="$unset partition field → all docs in one partition") + + +def test_stddevpop_match_filters_to_empty(collection): + """$match filtering all documents before $setWindowFields — zero output.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$match": {"value": {"$gt": 999}}}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + assertSuccess(result, [], msg="$match filters to empty before $setWindowFields → no output") + + +def test_stddevpop_date_value_as_expression_ignored(collection): + """$stdDevPop with Date value in expression field — non-numeric, ignored.""" + docs = [ + {"_id": 1, "partition": "A", "value": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": datetime(2023, 6, 1, tzinfo=timezone.utc)}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Only doc 2 is numeric → single value in whole partition → stdDevPop = 0.0 + expected = [ + { + "_id": 1, + "partition": "A", + "value": datetime(2023, 1, 1, tzinfo=timezone.utc), + "result": 0.0, + }, + {"_id": 2, "partition": "A", "value": 20, "result": 0.0}, + { + "_id": 3, + "partition": "A", + "value": datetime(2023, 6, 1, tzinfo=timezone.utc), + "result": 0.0, + }, + ] + assertSuccess(result, expected, msg="Date values in expression field are non-numeric — ignored") + + +def test_stddevpop_addfields_then_setwindowfields(collection): + """$addFields creating a field, then $setWindowFields using it.""" + docs = [ + {"_id": 1, "partition": "A", "x": 5, "y": 2}, + {"_id": 2, "partition": "A", "x": 10, "y": 3}, + {"_id": 3, "partition": "A", "x": 15, "y": 1}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$addFields": {"computed": {"$multiply": ["$x", "$y"]}}}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$computed", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # computed: [10, 30, 15] → mean=55/3≈18.33, stdDevPop ≈ 8.498 + expected = [ + {"_id": 1, "partition": "A", "x": 5, "y": 2, "computed": 10, "result": 8.498365855987974}, + {"_id": 2, "partition": "A", "x": 10, "y": 3, "computed": 30, "result": 8.498365855987974}, + {"_id": 3, "partition": "A", "x": 15, "y": 1, "computed": 15, "result": 8.498365855987974}, + ] + assertSuccess(result, expected, msg="$addFields + $setWindowFields on computed field") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_non_numeric_handling.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_non_numeric_handling.py new file mode 100644 index 000000000..5dbcd5dcf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_non_numeric_handling.py @@ -0,0 +1,803 @@ +""" +Tests for $stdDevPop null, missing, and non-numeric value handling. + +Covers: null values, missing fields, strings, booleans, arrays, objects, +mixed numeric and non-numeric in same frame, all non-numeric returns null, +and edge cases (single value returns 0, all identical returns 0). +""" + +from datetime import datetime, timezone + +from bson import Binary, ObjectId, Regex + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_stdDevPop_null_values_ignored(collection): + """$stdDevPop ignores null values in the expression field.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": None}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": None, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="null values ignored, stdDevPop of 10,30 = 10") + + +def test_stdDevPop_missing_field_ignored(collection): + """$stdDevPop ignores documents where the expression field is missing.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A"}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="missing field ignored, stdDevPop of 10,30 = 10") + + +def test_stdDevPop_string_values_ignored(collection): + """$stdDevPop ignores string values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": "hello"}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": "hello", "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="string values ignored") + + +def test_stdDevPop_boolean_values_ignored(collection): + """$stdDevPop ignores boolean values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": True}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": True, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="boolean values ignored") + + +def test_stdDevPop_array_values_ignored(collection): + """$stdDevPop ignores array values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": [1, 2, 3]}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": [1, 2, 3], "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="array values ignored") + + +def test_stdDevPop_object_values_ignored(collection): + """$stdDevPop ignores object/document values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": {"nested": 99}}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": {"nested": 99}, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="object values ignored") + + +def test_stdDevPop_all_non_numeric_returns_null(collection): + """$stdDevPop returns null when all values in frame are non-numeric.""" + docs = [ + {"_id": 1, "partition": "A", "value": "a"}, + {"_id": 2, "partition": "A", "value": None}, + {"_id": 3, "partition": "A"}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": "a", "result": None}, + {"_id": 2, "partition": "A", "value": None, "result": None}, + {"_id": 3, "partition": "A", "result": None}, + ] + assertSuccess(result, expected, msg="all non-numeric in frame returns null") + + +def test_stdDevPop_mixed_numeric_non_numeric_sliding(collection): + """$stdDevPop in sliding window with mix of numeric and non-numeric values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10.0}, + {"_id": 2, "partition": "A", "value": "skip"}, + {"_id": 3, "partition": "A", "value": 20.0}, + {"_id": 4, "partition": "A", "value": None}, + {"_id": 5, "partition": "A", "value": 30.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": [-2, 2]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Window [-2, 2] (5-doc centered): + # Row 1: frame [10.0, "skip", 20.0] → numerics [10, 20] → stdDevPop = 5.0 + # Row 2: frame [10.0, "skip", 20.0, None] → numerics [10, 20] → 5.0 + # Row 3: frame [10.0, "skip", 20.0, None, 30.0] → numerics [10, 20, 30] → 8.165 + # Row 4: frame ["skip", 20.0, None, 30.0] → numerics [20, 30] → 5.0 + # Row 5: frame [20.0, None, 30.0] → numerics [20, 30] → 5.0 + expected = [ + {"_id": 1, "partition": "A", "value": 10.0, "result": 5.0}, + {"_id": 2, "partition": "A", "value": "skip", "result": 5.0}, + {"_id": 3, "partition": "A", "value": 20.0, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "value": None, "result": 5.0}, + {"_id": 5, "partition": "A", "value": 30.0, "result": 5.0}, + ] + assertSuccess(result, expected, msg="mixed types in sliding window — non-numeric ignored") + + +def test_stdDevPop_nulls_among_numerics_in_frame(collection): + """$stdDevPop ignores nulls in frame and computes on remaining numerics.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10.0}, + {"_id": 2, "partition": "A", "value": None}, + {"_id": 3, "partition": "A", "value": 30.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Whole partition: numerics [10, 30] → stdDevPop = 10.0 (null ignored) + expected = [ + {"_id": 1, "partition": "A", "value": 10.0, "result": 10.0}, + {"_id": 2, "partition": "A", "value": None, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30.0, "result": 10.0}, + ] + assertSuccess( + result, expected, msg="null values ignored, stdDevPop computed on remaining numerics" + ) + + +def test_stdDevPop_all_identical_values_returns_zero(collection): + """$stdDevPop returns 0 when all values in the frame are identical.""" + docs = [ + {"_id": 1, "partition": "A", "value": 7.0}, + {"_id": 2, "partition": "A", "value": 7.0}, + {"_id": 3, "partition": "A", "value": 7.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 7.0, "result": 0.0}, + {"_id": 2, "partition": "A", "value": 7.0, "result": 0.0}, + {"_id": 3, "partition": "A", "value": 7.0, "result": 0.0}, + ] + assertSuccess(result, expected, msg="identical values produce stdDevPop = 0") + + +def test_stdDevPop_negative_numbers(collection): + """$stdDevPop handles negative numbers correctly.""" + docs = [ + {"_id": 1, "partition": "A", "value": -10}, + {"_id": 2, "partition": "A", "value": 0}, + {"_id": 3, "partition": "A", "value": 10}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": -10, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": 0, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": 10, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="negative numbers handled correctly") + + +def test_stdDevPop_decimals(collection): + """$stdDevPop handles floating-point (double) values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1.5}, + {"_id": 2, "partition": "A", "value": 2.5}, + {"_id": 3, "partition": "A", "value": 3.5}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 1.5, "result": 0.816496580927726}, + {"_id": 2, "partition": "A", "value": 2.5, "result": 0.816496580927726}, + {"_id": 3, "partition": "A", "value": 3.5, "result": 0.816496580927726}, + ] + assertSuccess(result, expected, msg="floating-point values handled correctly") + + +def test_stdDevPop_expression_input(collection): + """$stdDevPop with computed expression as input.""" + docs = [ + {"_id": 1, "partition": "A", "x": 5, "y": 2}, + {"_id": 2, "partition": "A", "x": 10, "y": 2}, + {"_id": 3, "partition": "A", "x": 15, "y": 2}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": {"$multiply": ["$x", "$y"]}, + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "x": 5, "y": 2, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "x": 10, "y": 2, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "x": 15, "y": 2, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="expression input ($multiply) works") + + +def test_stddevpop_mixed_types_in_documents(collection): + """$stdDevPop over documents with diverse value types — only numerics participate.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": "hello"}, + {"_id": 3, "partition": "A", "value": True}, + {"_id": 4, "partition": "A", "value": datetime(2023, 6, 1, tzinfo=timezone.utc)}, + {"_id": 5, "partition": "A", "value": 30}, + {"_id": 6, "partition": "A", "value": None}, + {"_id": 7, "partition": "A", "value": [1, 2, 3]}, + {"_id": 8, "partition": "A", "value": {"nested": 99}}, + {"_id": 9, "partition": "A", "value": 50}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$project": {"_id": 1, "result": 1}}, + ], + "cursor": {}, + }, + ) + # Only numeric: [10, 30, 50] → mean=30, variance=(400+0+400)/3≈266.67, stddev≈16.33 + expected = [ + {"_id": 1, "result": 16.32993161855452}, + {"_id": 2, "result": 16.32993161855452}, + {"_id": 3, "result": 16.32993161855452}, + {"_id": 4, "result": 16.32993161855452}, + {"_id": 5, "result": 16.32993161855452}, + {"_id": 6, "result": 16.32993161855452}, + {"_id": 7, "result": 16.32993161855452}, + {"_id": 8, "result": 16.32993161855452}, + {"_id": 9, "result": 16.32993161855452}, + ] + assertSuccess(result, expected, msg="diverse non-numeric types ignored in whole partition") + + +def test_stddevpop_mixed_types_sliding_window(collection): + """$stdDevPop sliding window over documents with diverse types — per-frame filtering.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10.0}, + {"_id": 2, "partition": "A", "value": 20.0}, + {"_id": 3, "partition": "A", "value": "skip"}, + {"_id": 4, "partition": "A", "value": True}, + {"_id": 5, "partition": "A", "value": 30.0}, + {"_id": 6, "partition": "A", "value": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + {"_id": 7, "partition": "A", "value": 40.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Whole partition: numerics [10, 20, 30, 40] → stdDevPop = 11.180339887498949 + expected = [ + {"_id": 1, "partition": "A", "value": 10.0, "result": 11.180339887498949}, + {"_id": 2, "partition": "A", "value": 20.0, "result": 11.180339887498949}, + {"_id": 3, "partition": "A", "value": "skip", "result": 11.180339887498949}, + {"_id": 4, "partition": "A", "value": True, "result": 11.180339887498949}, + {"_id": 5, "partition": "A", "value": 30.0, "result": 11.180339887498949}, + { + "_id": 6, + "partition": "A", + "value": datetime(2023, 1, 1, tzinfo=timezone.utc), + "result": 11.180339887498949, + }, + {"_id": 7, "partition": "A", "value": 40.0, "result": 11.180339887498949}, + ] + assertSuccess(result, expected, msg="sliding window filters non-numeric types per frame") + + +def test_stddevpop_objectid_and_regex_and_binary_ignored(collection): + """$stdDevPop ignores ObjectId, Regex, and Binary values.""" + oid = ObjectId("507f1f77bcf86cd799439011") + docs = [ + {"_id": 1, "partition": "A", "value": oid}, + {"_id": 2, "partition": "A", "value": Regex("^test", "i")}, + {"_id": 3, "partition": "A", "value": Binary(b"\x01\x02\x03")}, + {"_id": 4, "partition": "A", "value": 10}, + {"_id": 5, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$project": {"_id": 1, "result": 1}}, + ], + "cursor": {}, + }, + ) + # Only numeric: [10, 30] → stdDevPop = 10.0 + expected = [ + {"_id": 1, "result": 10.0}, + {"_id": 2, "result": 10.0}, + {"_id": 3, "result": 10.0}, + {"_id": 4, "result": 10.0}, + {"_id": 5, "result": 10.0}, + ] + assertSuccess(result, expected, msg="ObjectId/Regex/Binary values ignored") + + +def test_stddevpop_all_non_numeric_diverse_types(collection): + """$stdDevPop returns null when all values are diverse non-numeric types.""" + docs = [ + {"_id": 1, "partition": "A", "value": "text"}, + {"_id": 2, "partition": "A", "value": True}, + {"_id": 3, "partition": "A", "value": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + {"_id": 4, "partition": "A", "value": [1, 2]}, + {"_id": 5, "partition": "A", "value": {"a": 1}}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # No numeric values → null + expected = [ + {"_id": 1, "partition": "A", "value": "text", "result": None}, + {"_id": 2, "partition": "A", "value": True, "result": None}, + { + "_id": 3, + "partition": "A", + "value": datetime(2023, 1, 1, tzinfo=timezone.utc), + "result": None, + }, + {"_id": 4, "partition": "A", "value": [1, 2], "result": None}, + {"_id": 5, "partition": "A", "value": {"a": 1}, "result": None}, + ] + assertSuccess(result, expected, msg="all diverse non-numeric types return null") + + +def test_stddevpop_numeric_among_diverse_types_cumulative(collection): + """$stdDevPop cumulative window with numerics scattered among diverse types.""" + docs = [ + {"_id": 1, "partition": "A", "value": "text"}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": datetime(2023, 6, 1, tzinfo=timezone.utc)}, + {"_id": 4, "partition": "A", "value": 20}, + {"_id": 5, "partition": "A", "value": True}, + {"_id": 6, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Cumulative, only numerics count: + # Row 1: no numeric → null + # Row 2: [10] → 0 + # Row 3: [10] → 0 (datetime ignored) + # Row 4: [10, 20] → 5.0 + # Row 5: [10, 20] → 5.0 (True ignored) + # Row 6: [10, 20, 30] → 8.165 + expected = [ + {"_id": 1, "partition": "A", "value": "text", "result": None}, + {"_id": 2, "partition": "A", "value": 10, "result": 0.0}, + { + "_id": 3, + "partition": "A", + "value": datetime(2023, 6, 1, tzinfo=timezone.utc), + "result": 0.0, + }, + {"_id": 4, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 5, "partition": "A", "value": True, "result": 5.0}, + {"_id": 6, "partition": "A", "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="cumulative window with numerics among diverse types") + + +def test_stdDevPop_numerics_among_non_numeric_sliding(collection): + """$stdDevPop sliding frame filters non-numerics and computes on remaining values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10.0}, + {"_id": 2, "partition": "A", "value": "a"}, + {"_id": 3, "partition": "A", "value": 30.0}, + {"_id": 4, "partition": "A", "value": "b"}, + {"_id": 5, "partition": "A", "value": 50.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": [-2, 2]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Window [-2, 2] (5-doc centered): + # Row 1: [10.0, "a", 30.0] → numerics [10, 30] → stdDevPop = 10.0 + # Row 2: [10.0, "a", 30.0, "b"] → numerics [10, 30] → 10.0 + # Row 3: [10.0, "a", 30.0, "b", 50.0] → numerics [10, 30, 50] → 16.3299... + # Row 4: ["a", 30.0, "b", 50.0] → numerics [30, 50] → 10.0 + # Row 5: [30.0, "b", 50.0] → numerics [30, 50] → 10.0 + expected = [ + {"_id": 1, "partition": "A", "value": 10.0, "result": 10.0}, + {"_id": 2, "partition": "A", "value": "a", "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30.0, "result": 16.32993161855452}, + {"_id": 4, "partition": "A", "value": "b", "result": 10.0}, + {"_id": 5, "partition": "A", "value": 50.0, "result": 10.0}, + ] + assertSuccess(result, expected, msg="non-numeric values filtered in sliding frame") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_numeric_precision.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_numeric_precision.py new file mode 100644 index 000000000..17d6a5e80 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_numeric_precision.py @@ -0,0 +1,650 @@ +""" +Tests for $stdDevPop numeric type mixing, overflow edge cases, +algorithmic precision validation, and Decimal128 type handling. + +Covers: Int32/Int64/Double mixing, Int64 near MAX_LONG (overflow risk when +squaring), catastrophic cancellation in variance calculation, known exact +results, very small differences, consistency between window modes, +Decimal128 (NumberDecimal) values, high-precision Decimal128, mixed Decimal128 +with other numeric types, and Decimal128 special values (NaN, Infinity). +""" + +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertResult, assertSuccess, assertSuccessNaN +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Gt, Lte, PerDoc + + +def _run_stddevpop(collection, docs, window, partition_by="$partition"): + """Helper to run $stdDevPop pipeline.""" + collection.insert_many(docs) + pipeline = [ + { + "$setWindowFields": { + "partitionBy": partition_by, + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": window, + } + }, + } + } + ] + return execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + + +def test_stddevpop_all_int32_values(collection): + """$stdDevPop with all Int32 values produces Double result.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="all Int32 values produce correct Double result") + + +def test_stddevpop_all_int64_values(collection): + """$stdDevPop with all Int64 values produces correct result.""" + docs = [ + {"_id": 1, "partition": "A", "value": Int64(10)}, + {"_id": 2, "partition": "A", "value": Int64(20)}, + {"_id": 3, "partition": "A", "value": Int64(30)}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Int64(10), "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": Int64(20), "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": Int64(30), "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="all Int64 values compute correctly") + + +def test_stddevpop_mixed_int32_int64_double(collection): + """$stdDevPop with mixed Int32 + Int64 + Double in same frame.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": Int64(20)}, + {"_id": 3, "partition": "A", "value": 30.0}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": Int64(20), "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": 30.0, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="mixed Int32 + Int64 + Double type promotion works") + + +def test_stddevpop_large_int64_near_max(collection): + """$stdDevPop with Int64 values near MAX_LONG — squaring would overflow 64-bit.""" + docs = [ + {"_id": 1, "partition": "A", "value": Int64(9223372036854775806)}, + {"_id": 2, "partition": "A", "value": Int64(9223372036854775807)}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # Both values round to the same float64 (2^63-1 and 2^63-2 differ by 1 ULP at this scale), + # so the server sees them as identical → stdDevPop = 0.0 + expected = [ + {"_id": 1, "partition": "A", "value": Int64(9223372036854775806), "result": 0.0}, + {"_id": 2, "partition": "A", "value": Int64(9223372036854775807), "result": 0.0}, + ] + assertSuccess(result, expected, msg="Int64 near MAX_LONG does not overflow") + + +def test_stddevpop_large_int64_spread(collection): + """$stdDevPop with widely spread Int64 values — tests numeric stability.""" + docs = [ + {"_id": 1, "partition": "A", "value": Int64(0)}, + {"_id": 2, "partition": "A", "value": Int64(4611686018427387903)}, + {"_id": 3, "partition": "A", "value": Int64(9223372036854775807)}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # Verify 3 docs returned, each with a positive result (exact value depends on precision) + checks = PerDoc( + {"result": Gt(0)}, + {"result": Gt(0)}, + {"result": Gt(0)}, + ) + assertResult(result, expected=checks, msg="Large Int64 spread produces positive result") + + +def test_stddevpop_known_exact_result(collection): + """$stdDevPop of [2,4,4,4,5,5,7,9] = exactly 2.0.""" + docs = [ + {"_id": i, "partition": "A", "value": v} for i, v in enumerate([2, 4, 4, 4, 5, 5, 7, 9], 1) + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": i, "partition": "A", "value": v, "result": 2.0} + for i, v in enumerate([2, 4, 4, 4, 5, 5, 7, 9], 1) + ] + assertSuccess(result, expected, msg="stdDevPop of [2,4,4,4,5,5,7,9] must be exactly 2.0") + + +def test_stddevpop_identical_floats_exactly_zero(collection): + """$stdDevPop of identical float values must be exactly 0.0, not epsilon.""" + docs = [ + {"_id": 1, "partition": "A", "value": 3.0}, + {"_id": 2, "partition": "A", "value": 3.0}, + {"_id": 3, "partition": "A", "value": 3.0}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 3.0, "result": 0.0}, + {"_id": 2, "partition": "A", "value": 3.0, "result": 0.0}, + {"_id": 3, "partition": "A", "value": 3.0, "result": 0.0}, + ] + assertSuccess(result, expected, msg="identical floats produce exactly 0.0") + + +def test_stddevpop_single_element_sliding_window(collection): + """$stdDevPop returns 0 when sliding window frame has exactly one value.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10.0}, + {"_id": 2, "partition": "A", "value": 20.0}, + {"_id": 3, "partition": "A", "value": 30.0}, + {"_id": 4, "partition": "A", "value": 40.0}, + {"_id": 5, "partition": "A", "value": 50.0}, + ] + result = _run_stddevpop(collection, docs, {"documents": [0, 0]}) + # Window [0, 0] — each frame has exactly one value → stdDevPop = 0 + expected = [ + {"_id": 1, "partition": "A", "value": 10.0, "result": 0}, + {"_id": 2, "partition": "A", "value": 20.0, "result": 0}, + {"_id": 3, "partition": "A", "value": 30.0, "result": 0}, + {"_id": 4, "partition": "A", "value": 40.0, "result": 0}, + {"_id": 5, "partition": "A", "value": 50.0, "result": 0}, + ] + assertSuccess(result, expected, msg="single element in sliding frame returns 0") + + +def test_stddevpop_catastrophic_cancellation(collection): + """$stdDevPop of [1000000001, 1000000002, 1000000003] — naive sum-of-squares fails.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1000000001}, + {"_id": 2, "partition": "A", "value": 1000000002}, + {"_id": 3, "partition": "A", "value": 1000000003}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # stdDevPop of [N, N+1, N+2] = sqrt(2/3) ≈ 0.8165 regardless of N + expected = [ + {"_id": 1, "partition": "A", "value": 1000000001, "result": 0.816496580927726}, + {"_id": 2, "partition": "A", "value": 1000000002, "result": 0.816496580927726}, + {"_id": 3, "partition": "A", "value": 1000000003, "result": 0.816496580927726}, + ] + assertSuccess( + result, + expected, + msg="catastrophic cancellation handled — correct stddev for large offset values", + ) + + +def test_stddevpop_very_small_differences(collection): + """$stdDevPop with values that differ by very small amounts.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1.0000001}, + {"_id": 2, "partition": "A", "value": 1.0000002}, + {"_id": 3, "partition": "A", "value": 1.0000003}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # The stdDevPop should be approximately 8.165e-8 — verify positive and tiny + checks = PerDoc( + {"result": [Gt(0), Lte(0.001)]}, + {"result": [Gt(0), Lte(0.001)]}, + {"result": [Gt(0), Lte(0.001)]}, + ) + assertResult( + result, expected=checks, msg="Small differences produce very small positive stddev" + ) + + +def test_stddevpop_alternating_large_values(collection): + """$stdDevPop with alternating sign large values — stress variance accumulator.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1e15}, + {"_id": 2, "partition": "A", "value": -1e15}, + {"_id": 3, "partition": "A", "value": 1e15}, + {"_id": 4, "partition": "A", "value": -1e15}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # Mean = 0, variance = (4 * 1e30) / 4 = 1e30, stddev = 1e15 + expected = [ + {"_id": 1, "partition": "A", "value": 1e15, "result": 1e15}, + {"_id": 2, "partition": "A", "value": -1e15, "result": 1e15}, + {"_id": 3, "partition": "A", "value": 1e15, "result": 1e15}, + {"_id": 4, "partition": "A", "value": -1e15, "result": 1e15}, + ] + assertSuccess(result, expected, msg="alternating large values produce correct stddev") + + +def test_stddevpop_whole_partition_vs_explicit_unbounded(collection): + """$stdDevPop no-window vs explicit unbounded-unbounded must be identical.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + # Run with explicit unbounded-unbounded + result1 = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "explicit": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + }, + "default": { + "$stdDevPop": "$value", + }, + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + { + "_id": 1, + "partition": "A", + "value": 10, + "explicit": 8.16496580927726, + "default": 8.16496580927726, + }, + { + "_id": 2, + "partition": "A", + "value": 20, + "explicit": 8.16496580927726, + "default": 8.16496580927726, + }, + { + "_id": 3, + "partition": "A", + "value": 30, + "explicit": 8.16496580927726, + "default": 8.16496580927726, + }, + ] + assertSuccess(result1, expected, msg="explicit unbounded-unbounded equals default (no window)") + + +def test_stddevpop_int32_sum_of_squares_overflow(collection): + """$stdDevPop with Int32 values where sum-of-squares would overflow Int32.""" + # Max Int32 = 2147483647. Values around 50000: 50000^2 = 2.5e9 > 2^31 + docs = [ + {"_id": 1, "partition": "A", "value": 50000}, + {"_id": 2, "partition": "A", "value": 50001}, + {"_id": 3, "partition": "A", "value": 50002}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 50000, "result": 0.816496580927726}, + {"_id": 2, "partition": "A", "value": 50001, "result": 0.816496580927726}, + {"_id": 3, "partition": "A", "value": 50002, "result": 0.816496580927726}, + ] + assertSuccess(result, expected, msg="Int32 values that would overflow Int32 sum-of-squares") + + +def test_stddevpop_very_large_value(collection): + """$stdDevPop with very large numeric value (1e308) — all identical returns 0.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1e308}, + {"_id": 2, "partition": "A", "value": 1e308}, + {"_id": 3, "partition": "A", "value": 1e308}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 1e308, "result": 0.0}, + {"_id": 2, "partition": "A", "value": 1e308, "result": 0.0}, + {"_id": 3, "partition": "A", "value": 1e308, "result": 0.0}, + ] + assertSuccess(result, expected, msg="very large identical values return 0") + + +def test_stddevpop_sequential_values_1_to_10(collection): + """$stdDevPop over sequential values 1..10 with known exact result.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1}, + {"_id": 2, "partition": "A", "value": 2}, + {"_id": 3, "partition": "A", "value": 3}, + {"_id": 4, "partition": "A", "value": 4}, + {"_id": 5, "partition": "A", "value": 5}, + {"_id": 6, "partition": "A", "value": 6}, + {"_id": 7, "partition": "A", "value": 7}, + {"_id": 8, "partition": "A", "value": 8}, + {"_id": 9, "partition": "A", "value": 9}, + {"_id": 10, "partition": "A", "value": 10}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$match": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + # stdDevPop of 1..10: sqrt(82.5/10) = sqrt(8.25) ≈ 2.8723 + expected = [ + {"_id": 1, "partition": "A", "value": 1, "result": 2.8722813232690143}, + ] + assertSuccess(result, expected, msg="sequential 1..10 known stdDevPop result") + + +def _run_stddevpop_decimal(collection, docs, window, extra_stages=None): + """Helper to run $stdDevPop pipeline with optional extra stages.""" + collection.insert_many(docs) + pipeline = [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": window, + } + }, + } + } + ] + if extra_stages: + pipeline.extend(extra_stages) + return execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + + +def test_stddevpop_pure_decimal128_values(collection): + """$stdDevPop with pure Decimal128 values computes correctly.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Decimal128("20")}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + result = _run_stddevpop_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": Decimal128("20"), "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="pure Decimal128 values compute stdDevPop correctly") + + +def test_stddevpop_decimal128_with_double(collection): + """$stdDevPop with mixed Decimal128 and Double values.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": 20.0}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + result = _run_stddevpop_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": 20.0, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="mixed Decimal128 and Double compute correctly") + + +def test_stddevpop_decimal128_with_int32(collection): + """$stdDevPop with mixed Decimal128 and Int32 values.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + result = _run_stddevpop_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="mixed Decimal128 and Int32 compute correctly") + + +def test_stddevpop_decimal128_with_int64(collection): + """$stdDevPop with mixed Decimal128 and Int64 values.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Int64(20)}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + result = _run_stddevpop_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": Int64(20), "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="mixed Decimal128 and Int64 compute correctly") + + +def test_stddevpop_decimal128_all_types_mixed(collection): + """$stdDevPop with Decimal128 + Double + Int32 + Int64 all in same frame.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": 20.0}, + {"_id": 3, "partition": "A", "value": 30}, + {"_id": 4, "partition": "A", "value": Int64(40)}, + ] + result = _run_stddevpop_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": 11.180339887498949}, + {"_id": 2, "partition": "A", "value": 20.0, "result": 11.180339887498949}, + {"_id": 3, "partition": "A", "value": 30, "result": 11.180339887498949}, + {"_id": 4, "partition": "A", "value": Int64(40), "result": 11.180339887498949}, + ] + assertSuccess(result, expected, msg="all four numeric types mixed in same frame") + + +def test_stddevpop_decimal128_high_precision(collection): + """$stdDevPop with high-precision Decimal128 values preserves precision.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("1.000000000000000000000000000000001")}, + {"_id": 2, "partition": "A", "value": Decimal128("2.000000000000000000000000000000001")}, + {"_id": 3, "partition": "A", "value": Decimal128("3.000000000000000000000000000000001")}, + ] + extra_stages = [ + {"$addFields": {"result": {"$round": ["$result", 3]}}}, + {"$project": {"_id": 1, "result": 1}}, + ] + result = _run_stddevpop_decimal( + collection, docs, {"documents": ["unbounded", "unbounded"]}, extra_stages=extra_stages + ) + expected = [ + {"_id": 1, "result": 0.816}, + {"_id": 2, "result": 0.816}, + {"_id": 3, "result": 0.816}, + ] + assertSuccess(result, expected, msg="high-precision Decimal128 values produce ~0.816") + + +def test_stddevpop_decimal128_near_zero(collection): + """$stdDevPop with very small Decimal128 values near zero.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("0.0000000000000000000000000000000001")}, + {"_id": 2, "partition": "A", "value": Decimal128("0.0000000000000000000000000000000002")}, + {"_id": 3, "partition": "A", "value": Decimal128("0.0000000000000000000000000000000003")}, + ] + extra_stages = [ + { + "$addFields": { + "valid": { + "$and": [ + {"$ne": ["$result", None]}, + {"$gte": ["$result", 0]}, + ] + } + } + }, + {"$project": {"_id": 1, "valid": 1}}, + ] + result = _run_stddevpop_decimal( + collection, docs, {"documents": ["unbounded", "unbounded"]}, extra_stages=extra_stages + ) + expected = [ + {"_id": 1, "valid": True}, + {"_id": 2, "valid": True}, + {"_id": 3, "valid": True}, + ] + assertSuccess(result, expected, msg="near-zero Decimal128 produces non-negative result") + + +def test_stddevpop_decimal128_nan_special(collection): + """$stdDevPop with Decimal128 NaN — NaN is numeric and poisons the calculation.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Decimal128("NaN")}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + result = _run_stddevpop_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": float("nan")}, + {"_id": 2, "partition": "A", "value": Decimal128("NaN"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": float("nan")}, + ] + assertSuccessNaN(result, expected, msg="Decimal128 NaN is numeric and poisons stdDevPop to NaN") + + +def test_stddevpop_decimal128_infinity_special(collection): + """$stdDevPop with Decimal128 Infinity special value.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Decimal128("Infinity")}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + extra_stages = [ + { + "$addFields": { + "has_result": {"$ne": ["$result", None]}, + } + }, + {"$project": {"_id": 1, "has_result": 1}}, + ] + result = _run_stddevpop_decimal( + collection, docs, {"documents": ["unbounded", "unbounded"]}, extra_stages=extra_stages + ) + expected = [ + {"_id": 1, "has_result": True}, + {"_id": 2, "has_result": True}, + {"_id": 3, "has_result": True}, + ] + assertSuccess(result, expected, msg="Decimal128 Infinity produces a non-null result") + + +def test_stddevpop_decimal128_neg_infinity_special(collection): + """$stdDevPop with Decimal128 -Infinity special value.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Decimal128("-Infinity")}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + extra_stages = [ + { + "$addFields": { + "has_result": {"$ne": ["$result", None]}, + } + }, + {"$project": {"_id": 1, "has_result": 1}}, + ] + result = _run_stddevpop_decimal( + collection, docs, {"documents": ["unbounded", "unbounded"]}, extra_stages=extra_stages + ) + expected = [ + {"_id": 1, "has_result": True}, + {"_id": 2, "has_result": True}, + {"_id": 3, "has_result": True}, + ] + assertSuccess(result, expected, msg="Decimal128 -Infinity produces a non-null result") + + +def test_stddevpop_decimal128_sliding_window(collection): + """$stdDevPop with Decimal128 values in a sliding window.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Decimal128("20")}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + {"_id": 4, "partition": "A", "value": Decimal128("40")}, + {"_id": 5, "partition": "A", "value": Decimal128("50")}, + ] + result = _run_stddevpop_decimal(collection, docs, {"documents": [-1, 0]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": 0}, + {"_id": 2, "partition": "A", "value": Decimal128("20"), "result": 5.0}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": 5.0}, + {"_id": 4, "partition": "A", "value": Decimal128("40"), "result": 5.0}, + {"_id": 5, "partition": "A", "value": Decimal128("50"), "result": 5.0}, + ] + assertSuccess(result, expected, msg="Decimal128 values in sliding window") + + +def test_stddevpop_decimal128_identical_values(collection): + """$stdDevPop with identical Decimal128 values returns 0.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("42.5")}, + {"_id": 2, "partition": "A", "value": Decimal128("42.5")}, + {"_id": 3, "partition": "A", "value": Decimal128("42.5")}, + ] + result = _run_stddevpop_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("42.5"), "result": 0.0}, + {"_id": 2, "partition": "A", "value": Decimal128("42.5"), "result": 0.0}, + {"_id": 3, "partition": "A", "value": Decimal128("42.5"), "result": 0.0}, + ] + assertSuccess(result, expected, msg="identical Decimal128 values return 0") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_partitioning.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_partitioning.py new file mode 100644 index 000000000..b3ff7e2d9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_partitioning.py @@ -0,0 +1,745 @@ +""" +Tests for $stdDevPop with partitioning and sortBy variations. + +Covers: partition isolation, expression-based partitioning, null/missing +partition keys, descending sort, compound sort, multi-field sort, +no partitionBy, Date sortBy in documents mode, string sortBy. +""" + +from datetime import datetime, timezone + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_stdDevPop_partition_isolation(collection): + """$stdDevPop frame never crosses partition boundary.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + {"_id": 4, "partition": "B", "value": 100}, + {"_id": 5, "partition": "B", "value": 200}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 0.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + {"_id": 4, "partition": "B", "value": 100, "result": 0.0}, + {"_id": 5, "partition": "B", "value": 200, "result": 50.0}, + ] + assertSuccess(result, expected, msg="partition isolation — frames don't cross boundaries") + + +def test_stdDevPop_sliding_respects_partition_boundary(collection): + """Sliding window at partition edge does not include docs from other partition.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "B", "value": 30}, + {"_id": 4, "partition": "B", "value": 40}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": [-1, 1]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 3, "partition": "B", "value": 30, "result": 5.0}, + {"_id": 4, "partition": "B", "value": 40, "result": 5.0}, + ] + assertSuccess(result, expected, msg="sliding window respects partition boundary") + + +def test_stdDevPop_no_partitionBy(collection): + """$stdDevPop with no partitionBy treats entire collection as one partition.""" + docs = [ + {"_id": 1, "value": 10}, + {"_id": 2, "value": 20}, + {"_id": 3, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "value": 10, "result": 8.16496580927726}, + {"_id": 2, "value": 20, "result": 8.16496580927726}, + {"_id": 3, "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="no partitionBy = single partition over all docs") + + +def test_stdDevPop_partition_null_missing_grouped(collection): + """Null and missing partition keys share one partition.""" + docs = [ + {"_id": 1, "partition": None, "value": 10}, + {"_id": 2, "value": 20}, + {"_id": 3, "partition": None, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": None, "value": 10, "result": 8.16496580927726}, + {"_id": 2, "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": None, "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="null and missing partition keys share one partition") + + +def test_stdDevPop_descending_sort_cumulative(collection): + """$stdDevPop with descending sort reverses accumulation order.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": -1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 3, "partition": "A", "value": 30, "result": 0.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 1, "partition": "A", "value": 10, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="descending sort reverses cumulative order") + + +def test_stdDevPop_compound_sort(collection): + """$stdDevPop with compound sortBy for deterministic row ordering.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 1, "value": 40}, + {"_id": 3, "partition": "A", "score": 2, "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1, "_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": 0.0}, + {"_id": 2, "partition": "A", "score": 1, "value": 40, "result": 15.0}, + {"_id": 3, "partition": "A", "score": 2, "value": 20, "result": 12.472191289246473}, + ] + assertSuccess(result, expected, msg="compound sort for deterministic ordering") + + +def test_stdDevPop_single_doc_partitions(collection): + """$stdDevPop with single-doc partitions returns 0 for each.""" + docs = [ + {"_id": 1, "partition": "A", "value": 100}, + {"_id": 2, "partition": "B", "value": 200}, + {"_id": 3, "partition": "C", "value": 300}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 100, "result": 0.0}, + {"_id": 2, "partition": "B", "value": 200, "result": 0.0}, + {"_id": 3, "partition": "C", "value": 300, "result": 0.0}, + ] + assertSuccess(result, expected, msg="single-doc partitions return 0") + + +def test_stdDevPop_uneven_partitions(collection): + """$stdDevPop with partitions of different sizes.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + {"_id": 4, "partition": "B", "value": 5}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + {"_id": 4, "partition": "B", "value": 5, "result": 0.0}, + ] + assertSuccess(result, expected, msg="uneven partition sizes compute independently") + + +def test_stdDevPop_partition_by_expression(collection): + """$stdDevPop with expression-based partitionBy.""" + docs = [ + {"_id": 1, "value": 10}, + {"_id": 2, "value": 20}, + {"_id": 3, "value": 30}, + {"_id": 4, "value": 40}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": {"$mod": ["$_id", 2]}, + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "value": 10, "result": 10.0}, + {"_id": 2, "value": 20, "result": 10.0}, + {"_id": 3, "value": 30, "result": 10.0}, + {"_id": 4, "value": 40, "result": 10.0}, + ] + assertSuccess(result, expected, msg="expression-based partitionBy ($mod)") + + +def test_stdDevPop_date_sortby_documents_mode(collection): + """$stdDevPop with Date-valued sortBy in documents mode (not range).""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 1, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 1, tzinfo=timezone.utc), + "value": 30, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + "result": 0.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 1, tzinfo=timezone.utc), + "value": 20, + "result": 5.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 1, tzinfo=timezone.utc), + "value": 30, + "result": 8.16496580927726, + }, + ] + assertSuccess(result, expected, msg="Date sortBy works in documents mode") + + +def test_stdDevPop_date_sortby_sliding_documents_mode(collection): + """$stdDevPop with Date-valued sortBy and sliding window in documents mode.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 1, tzinfo=timezone.utc), + "value": 20.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 1, tzinfo=timezone.utc), + "value": 30.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 4, 1, tzinfo=timezone.utc), + "value": 40.0, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": [-1, 1]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10.0, + "result": 5.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 1, tzinfo=timezone.utc), + "value": 20.0, + "result": 8.16496580927726, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 1, tzinfo=timezone.utc), + "value": 30.0, + "result": 8.16496580927726, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 4, 1, tzinfo=timezone.utc), + "value": 40.0, + "result": 5.0, + }, + ] + assertSuccess(result, expected, msg="Date sortBy sliding window in documents mode") + + +def test_stdDevPop_string_sortby(collection): + """$stdDevPop with string-valued sortBy field in documents mode.""" + docs = [ + {"_id": 1, "partition": "A", "name": "alice", "value": 10}, + {"_id": 2, "partition": "A", "name": "bob", "value": 20}, + {"_id": 3, "partition": "A", "name": "carol", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"name": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "name": "alice", "value": 10, "result": 0.0}, + {"_id": 2, "partition": "A", "name": "bob", "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "name": "carol", "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="string sortBy works in documents mode") + + +def test_stdDevPop_missing_sortby_field_sorts_first(collection): + """Documents with missing sortBy field sort before others.""" + docs = [ + {"_id": 1, "partition": "A", "value": 100}, + {"_id": 2, "partition": "A", "score": 5, "value": 10}, + {"_id": 3, "partition": "A", "score": 10, "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 100, "result": 0.0}, + {"_id": 2, "partition": "A", "score": 5, "value": 10, "result": 45.0}, + {"_id": 3, "partition": "A", "score": 10, "value": 20, "result": 40.27681991198191}, + ] + assertSuccess(result, expected, msg="missing sortBy field sorts first (before all values)") + + +def test_stdDevPop_many_partitions_single_value_each(collection): + """$stdDevPop with many single-document partitions all return 0.""" + docs = [ + {"_id": 1, "partition": "P1", "value": 10}, + {"_id": 2, "partition": "P2", "value": 20}, + {"_id": 3, "partition": "P3", "value": 30}, + {"_id": 4, "partition": "P4", "value": 40}, + {"_id": 5, "partition": "P5", "value": 50}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "P1", "value": 10, "result": 0.0}, + {"_id": 2, "partition": "P2", "value": 20, "result": 0.0}, + {"_id": 3, "partition": "P3", "value": 30, "result": 0.0}, + {"_id": 4, "partition": "P4", "value": 40, "result": 0.0}, + {"_id": 5, "partition": "P5", "value": 50, "result": 0.0}, + ] + assertSuccess(result, expected, msg="many single-doc partitions all return 0") + + +def test_stdDevPop_empty_collection(collection): + """$stdDevPop on empty collection returns no documents.""" + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertSuccess(result, [], msg="empty collection returns no results") + + +def test_stdDevPop_single_document(collection): + """$stdDevPop on single document returns 0.""" + docs = [{"_id": 1, "partition": "A", "value": 42}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [{"_id": 1, "partition": "A", "value": 42, "result": 0.0}] + assertSuccess(result, expected, msg="single document returns 0") + + +def test_stdDevPop_large_partition_cumulative(collection): + """$stdDevPop cumulative over 100+ document partition for correctness at scale.""" + n = 100 + docs = [{"_id": i, "partition": "A", "value": i} for i in range(1, n + 1)] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$match": {"_id": 100}}, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 100, "partition": "A", "value": 100, "result": 28.86607004772212}, + ] + assertSuccess(result, expected, msg="large partition (100 docs) computes correctly") + + +def test_stdDevPop_large_partition_sliding(collection): + """$stdDevPop sliding window over 100+ document partition.""" + n = 100 + docs = [{"_id": i, "partition": "A", "value": i * 10} for i in range(1, n + 1)] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": [-2, 2]}, + } + }, + } + }, + {"$match": {"_id": 50}}, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 50, "partition": "A", "value": 500, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="large partition sliding window at midpoint") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_pipeline_stages.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_pipeline_stages.py new file mode 100644 index 000000000..3847262c5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_pipeline_stages.py @@ -0,0 +1,514 @@ +""" +Tests for $stdDevPop in pipeline context: $unionWith sub-pipeline, +$setWindowFields after $addFields/transform, and additional chaining patterns. + +Covers: $unionWith pipeline, three chained $setWindowFields, $setWindowFields +after $group, and $setWindowFields with $project between stages. +""" + +from documentdb_tests.framework.assertions import assertResult, assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Gt, PerDoc + + +def test_stddevpop_inside_unionwith_pipeline(collection): + """$stdDevPop inside a $unionWith sub-pipeline.""" + collection.insert_many( + [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + ] + ) + other_coll_name = collection.name + "_other" + other_coll = collection.database[other_coll_name] + other_coll.insert_many( + [ + {"_id": 3, "partition": "B", "value": 30}, + {"_id": 4, "partition": "B", "value": 40}, + ] + ) + try: + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + { + "$unionWith": { + "coll": other_coll_name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 3, "partition": "B", "value": 30, "result": 5.0}, + {"_id": 4, "partition": "B", "value": 40, "result": 5.0}, + ] + assertSuccess(result, expected, msg="$stdDevPop inside $unionWith sub-pipeline") + finally: + other_coll.drop() + + +def test_stddevpop_after_group_stage(collection): + """$stdDevPop on $group output — window over grouped results.""" + docs = [ + {"_id": 1, "category": "A", "value": 10}, + {"_id": 2, "category": "A", "value": 20}, + {"_id": 3, "category": "B", "value": 30}, + {"_id": 4, "category": "B", "value": 40}, + {"_id": 5, "category": "C", "value": 50}, + {"_id": 6, "category": "C", "value": 60}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$group": { + "_id": "$category", + "avgValue": {"$avg": "$value"}, + } + }, + {"$sort": {"_id": 1}}, + { + "$setWindowFields": { + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$avgValue", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Group averages: A=15, B=35, C=55 → stdDevPop of [15, 35, 55] = sqrt(200/3) ≈ 16.33 + expected = [ + {"_id": "A", "avgValue": 15.0, "result": 16.32993161855452}, + {"_id": "B", "avgValue": 35.0, "result": 16.32993161855452}, + {"_id": "C", "avgValue": 55.0, "result": 16.32993161855452}, + ] + assertSuccess(result, expected, msg="$setWindowFields after $group") + + +def test_stddevpop_project_between_two_window_stages(collection): + """$project between two $setWindowFields stages to reshape data.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "runningAvg": { + "$avg": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + {"$project": {"partition": 1, "runningAvg": 1}}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "stdDevOfAvg": { + "$stdDevPop": "$runningAvg", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Running averages: [10, 15, 20] + # stdDevPop of [10, 15, 20] = sqrt((25+0+25)/3) = sqrt(50/3) ≈ 4.082 + expected = [ + {"_id": 1, "partition": "A", "runningAvg": 10.0, "stdDevOfAvg": 4.08248290463863}, + {"_id": 2, "partition": "A", "runningAvg": 15.0, "stdDevOfAvg": 4.08248290463863}, + {"_id": 3, "partition": "A", "runningAvg": 20.0, "stdDevOfAvg": 4.08248290463863}, + ] + assertSuccess(result, expected, msg="$project between two $setWindowFields stages") + + +def test_stddevpop_three_chained_window_stages(collection): + """Three chained $setWindowFields stages, each using output of previous.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "cumSum": { + "$sum": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "cumAvg": { + "$avg": "$cumSum", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "stdDevResult": { + "$stdDevPop": "$cumAvg", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # cumSum: [10, 30, 60] + # cumAvg of cumSum: [10, 20, 33.33] + # stdDevPop of [10, 20, 33.33] ≈ 9.558 + assertResult( + result, + expected=PerDoc( + {"stdDevResult": Gt(0.0)}, + {"stdDevResult": Gt(0.0)}, + {"stdDevResult": Gt(0.0)}, + ), + msg="Three chained $setWindowFields stages produce positive stdDevResult", + ) + + +def test_stddevpop_after_unwind(collection): + """$stdDevPop after $unwind — window operates on unwound documents.""" + docs = [ + {"_id": 1, "partition": "A", "values": [10, 20, 30]}, + {"_id": 2, "partition": "A", "values": [40, 50]}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$unwind": "$values"}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"values": 1}, + "output": { + "result": { + "$stdDevPop": "$values", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$sort": {"values": 1}}, + ], + "cursor": {}, + }, + ) + # After unwind: 5 docs with values 10, 20, 30, 40, 50 + # stdDevPop of [10, 20, 30, 40, 50] = sqrt(200) ≈ 14.142 + expected = [ + {"_id": 1, "partition": "A", "values": 10, "result": 14.142135623730951}, + {"_id": 1, "partition": "A", "values": 20, "result": 14.142135623730951}, + {"_id": 1, "partition": "A", "values": 30, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "values": 40, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "values": 50, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="$stdDevPop after $unwind") + + +def test_stddevpop_with_limit_before(collection): + """$stdDevPop with $limit before $setWindowFields — operates on limited set.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + {"_id": 4, "partition": "A", "value": 40}, + {"_id": 5, "partition": "A", "value": 50}, + {"_id": 6, "partition": "A", "value": 60}, + {"_id": 7, "partition": "A", "value": 70}, + {"_id": 8, "partition": "A", "value": 80}, + {"_id": 9, "partition": "A", "value": 90}, + {"_id": 10, "partition": "A", "value": 100}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$sort": {"_id": 1}}, + {"$limit": 3}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # After limit: 3 docs with values 10, 20, 30 + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="$stdDevPop after $limit operates on limited set") + + +def test_stdDevPop_inside_lookup_pipeline(collection): + """$stdDevPop inside a $lookup sub-pipeline.""" + collection.insert_many( + [ + {"_id": 1, "category": "A"}, + {"_id": 2, "category": "B"}, + ] + ) + details_coll_name = collection.name + "_details" + details_coll = collection.database[details_coll_name] + details_coll.insert_many( + [ + {"_id": 10, "category": "A", "value": 10}, + {"_id": 11, "category": "A", "value": 20}, + {"_id": 12, "category": "A", "value": 30}, + {"_id": 13, "category": "B", "value": 5}, + {"_id": 14, "category": "B", "value": 15}, + ] + ) + try: + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$lookup": { + "from": details_coll_name, + "let": {"cat": "$category"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$category", "$$cat"]}}}, + { + "$setWindowFields": { + "sortBy": {"_id": 1}, + "output": { + "stdDev": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "as": "details", + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + expected = [ + { + "_id": 1, + "category": "A", + "details": [ + {"_id": 10, "category": "A", "value": 10, "stdDev": 8.16496580927726}, + {"_id": 11, "category": "A", "value": 20, "stdDev": 8.16496580927726}, + {"_id": 12, "category": "A", "value": 30, "stdDev": 8.16496580927726}, + ], + }, + { + "_id": 2, + "category": "B", + "details": [ + {"_id": 13, "category": "B", "value": 5, "stdDev": 5.0}, + {"_id": 14, "category": "B", "value": 15, "stdDev": 5.0}, + ], + }, + ] + assertSuccess(result, expected, msg="$stdDevPop inside $lookup sub-pipeline") + finally: + details_coll.drop() + + +def test_stdDevPop_chained_setWindowFields(collection): + """Chained $setWindowFields stages — second stage uses output of first.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "runningSum": { + "$sum": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "stdDevOfSum": { + "$stdDevPop": "$runningSum", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + expected = [ + { + "_id": 1, + "partition": "A", + "value": 10, + "runningSum": 10, + "stdDevOfSum": 20.548046676563253, + }, + { + "_id": 2, + "partition": "A", + "value": 20, + "runningSum": 30, + "stdDevOfSum": 20.548046676563253, + }, + { + "_id": 3, + "partition": "A", + "value": 30, + "runningSum": 60, + "stdDevOfSum": 20.548046676563253, + }, + ] + assertSuccess(result, expected, msg="chained $setWindowFields stages") + + +def test_stdDevPop_then_match(collection): + """$setWindowFields followed by $match on computed field.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "stdDev": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + {"$match": {"stdDev": {"$gt": 0}}}, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 2, "partition": "A", "value": 20, "stdDev": 5.0}, + {"_id": 3, "partition": "A", "value": 30, "stdDev": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="$match filters on window-computed field") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_range_mode_frames.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_range_mode_frames.py new file mode 100644 index 000000000..d726eb708 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_range_mode_frames.py @@ -0,0 +1,758 @@ +""" +Tests for $stdDevPop with range-mode windows (numeric and time-range). + +Covers: numeric range with gaps, unbounded/current combinations, +fractional range offsets, time-range with various units, partitioning +in range mode, and value-ties semantics. +""" + +from datetime import datetime, timezone + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_stdDevPop_range_numeric_sliding(collection): + """$stdDevPop with numeric range window [-10, 10].""" + docs = [ + {"_id": 1, "partition": "A", "score": 0, "value": 10}, + {"_id": 2, "partition": "A", "score": 5, "value": 20}, + {"_id": 3, "partition": "A", "score": 15, "value": 30}, + {"_id": 4, "partition": "A", "score": 25, "value": 40}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": [-10, 10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "score": 0, "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "score": 5, "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "score": 15, "value": 30, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "score": 25, "value": 40, "result": 5.0}, + ] + assertSuccess(result, expected, msg="numeric range [-10,10]") + + +def test_stdDevPop_range_zero_zero_includes_ties(collection): + """Range [0, 0] includes all docs with the same sortBy value (value-ties).""" + docs = [ + {"_id": 1, "partition": "A", "score": 10, "value": 5.0}, + {"_id": 2, "partition": "A", "score": 10, "value": 15.0}, + {"_id": 3, "partition": "A", "score": 10, "value": 25.0}, + {"_id": 4, "partition": "A", "score": 20, "value": 100.0}, + {"_id": 5, "partition": "A", "score": 20, "value": 110.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": [0, 0]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # score=10 ties: stdDevPop([5, 15, 25]) = 8.165... + # score=20 ties: stdDevPop([100, 110]) = 5.0 + expected = [ + {"_id": 1, "partition": "A", "score": 10, "value": 5.0, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "score": 10, "value": 15.0, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "score": 10, "value": 25.0, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "score": 20, "value": 100.0, "result": 5.0}, + {"_id": 5, "partition": "A", "score": 20, "value": 110.0, "result": 5.0}, + ] + assertSuccess(result, expected, msg="range [0,0] includes value-ties") + + +def test_stdDevPop_range_empty_returns_null(collection): + """Range window that excludes all docs returns null.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 100, "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": [-5, -3]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": None}, + {"_id": 2, "partition": "A", "score": 100, "value": 20, "result": None}, + ] + assertSuccess(result, expected, msg="empty range frame returns null") + + +def test_stdDevPop_range_partitioned(collection): + """$stdDevPop range window respects partition boundaries.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 2, "value": 20}, + {"_id": 3, "partition": "B", "score": 1, "value": 100}, + {"_id": 4, "partition": "B", "score": 2, "value": 200}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "score": 2, "value": 20, "result": 5.0}, + {"_id": 3, "partition": "B", "score": 1, "value": 100, "result": 50.0}, + {"_id": 4, "partition": "B", "score": 2, "value": 200, "result": 50.0}, + ] + assertSuccess(result, expected, msg="range window respects partition boundaries") + + +def test_stdDevPop_time_range_sliding_day(collection): + """$stdDevPop with time-range sliding window [-1, 0] unit=day.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 3, tzinfo=timezone.utc), + "value": 30.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 5, tzinfo=timezone.utc), + "value": 40.0, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": [-1, 0], "unit": "day"}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Row 1 (Jan 1): frame includes only Jan 1 → stdDevPop([10]) = 0 + # Row 2 (Jan 2): frame includes Jan 1 and Jan 2 → stdDevPop([10, 20]) = 5.0 + # Row 3 (Jan 3): frame includes Jan 2 and Jan 3 → stdDevPop([20, 30]) = 5.0 + # Row 4 (Jan 5): frame includes only Jan 5 (Jan 3 is 2 days away) → stdDevPop([40]) = 0 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10.0, + "result": 0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20.0, + "result": 5.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 3, tzinfo=timezone.utc), + "value": 30.0, + "result": 5.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 5, tzinfo=timezone.utc), + "value": 40.0, + "result": 0, + }, + ] + assertSuccess(result, expected, msg="time-range [-1,0] unit=day, gap excludes distant docs") + + +def test_stdDevPop_time_range_hour_unit(collection): + """$stdDevPop with time-range unit=hour.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, tzinfo=timezone.utc), + "value": 10.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 1, 0, tzinfo=timezone.utc), + "value": 20.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 2, 0, tzinfo=timezone.utc), + "value": 30.0, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": [-1, 0], "unit": "hour"}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Row 1 (00:00): frame includes only 00:00 → stdDevPop([10]) = 0 + # Row 2 (01:00): frame includes 00:00 and 01:00 → stdDevPop([10, 20]) = 5.0 + # Row 3 (02:00): frame includes 01:00 and 02:00 → stdDevPop([20, 30]) = 5.0 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, tzinfo=timezone.utc), + "value": 10.0, + "result": 0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 1, 0, tzinfo=timezone.utc), + "value": 20.0, + "result": 5.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 2, 0, tzinfo=timezone.utc), + "value": 30.0, + "result": 5.0, + }, + ] + assertSuccess(result, expected, msg="time-range unit=hour") + + +def test_stdDevPop_time_range_week_unit(collection): + """$stdDevPop with time-range unit=week.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 8, tzinfo=timezone.utc), + "value": 20.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 30.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 29, tzinfo=timezone.utc), + "value": 50.0, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": [-1, 0], "unit": "week"}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Row 1 (Jan 1): frame includes only Jan 1 → stdDevPop([10]) = 0 + # Row 2 (Jan 8): frame includes Jan 1 and Jan 8 → stdDevPop([10, 20]) = 5.0 + # Row 3 (Jan 15): frame includes Jan 8 and Jan 15 → stdDevPop([20, 30]) = 5.0 + # Row 4 (Jan 29): 2 weeks gap from Jan 15, only itself → stdDevPop([50]) = 0 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10.0, + "result": 0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 8, tzinfo=timezone.utc), + "value": 20.0, + "result": 5.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 30.0, + "result": 5.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 29, tzinfo=timezone.utc), + "value": 50.0, + "result": 0, + }, + ] + assertSuccess(result, expected, msg="time-range unit=week, gap at 4th doc") + + +def test_stdDevPop_range_unbounded_unbounded(collection): + """$stdDevPop with range [unbounded, unbounded] covers whole partition.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 5, "value": 20}, + {"_id": 3, "partition": "A", "score": 100, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "score": 5, "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "score": 100, "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="range [unbounded, unbounded] = whole partition") + + +def test_stdDevPop_range_fractional_bounds(collection): + """$stdDevPop with fractional range bounds [-0.5, 0.5].""" + # Scores spaced 0.3 apart so each row's [-0.5, 0.5] frame includes its neighbors + docs = [ + {"_id": 1, "partition": "A", "score": 1.0, "value": 10.0}, + {"_id": 2, "partition": "A", "score": 1.3, "value": 20.0}, + {"_id": 3, "partition": "A", "score": 1.6, "value": 30.0}, + {"_id": 4, "partition": "A", "score": 1.9, "value": 40.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": [-0.5, 0.5]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Row 1 (score=1.0): frame [0.5, 1.5] → includes scores 1.0, 1.3 → stdDevPop([10, 20]) = 5.0 + # Row 2 (score=1.3): frame [0.8, 1.8] → includes scores 1.0, 1.3, 1.6 → stdDevPop([10, 20, 30]) + # Row 3 (score=1.6): frame [1.1, 2.1] → includes scores 1.3, 1.6, 1.9 → stdDevPop([20, 30, 40]) + # Row 4 (score=1.9): frame [1.4, 2.4] → includes scores 1.6, 1.9 → stdDevPop([30, 40]) = 5.0 + expected = [ + {"_id": 1, "partition": "A", "score": 1.0, "value": 10.0, "result": 5.0}, + {"_id": 2, "partition": "A", "score": 1.3, "value": 20.0, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "score": 1.6, "value": 30.0, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "score": 1.9, "value": 40.0, "result": 5.0}, + ] + assertSuccess(result, expected, msg="fractional bounds [-0.5, 0.5] with close-spaced scores") + + +def test_stdDevPop_range_mixed_unbounded_numeric(collection): + """$stdDevPop with mixed [unbounded, 5] numeric range.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 3, "value": 20}, + {"_id": 3, "partition": "A", "score": 10, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": ["unbounded", 5]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "score": 3, "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "score": 10, "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="range [unbounded, 5] mixed bound") + + +def test_stdDevPop_range_negative_sort_values_spanning_zero(collection): + """$stdDevPop with negative sort values spanning zero.""" + docs = [ + {"_id": 1, "partition": "A", "score": -5, "value": 10}, + {"_id": 2, "partition": "A", "score": 0, "value": 20}, + {"_id": 3, "partition": "A", "score": 5, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": [-5, 5]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "score": -5, "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "score": 0, "value": 20, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "score": 5, "value": 30, "result": 5.0}, + ] + assertSuccess(result, expected, msg="negative sort values spanning zero") + + +def test_stdDevPop_range_unbounded_current(collection): + """$stdDevPop with range [unbounded, current].""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 2, "value": 20}, + {"_id": 3, "partition": "A", "score": 3, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": 0.0}, + {"_id": 2, "partition": "A", "score": 2, "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "score": 3, "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="range [unbounded, current]") + + +def test_stdDevPop_range_current_unbounded(collection): + """$stdDevPop with range [current, unbounded].""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 2, "value": 20}, + {"_id": 3, "partition": "A", "score": 3, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": ["current", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "score": 2, "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "score": 3, "value": 30, "result": 0}, + ] + assertSuccess(result, expected, msg="range [current, unbounded]") + + +def test_stdDevPop_time_range_unbounded_current(collection): + """$stdDevPop with time-range [unbounded, current] unit=month.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 3, 15, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 6, 15, tzinfo=timezone.utc), + "value": 30, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": ["unbounded", "current"], "unit": "month"}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10, + "result": 0.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 3, 15, tzinfo=timezone.utc), + "value": 20, + "result": 5.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 6, 15, tzinfo=timezone.utc), + "value": 30, + "result": 8.16496580927726, + }, + ] + assertSuccess(result, expected, msg="time-range [unbounded, current] unit=month") + + +def test_stddevpop_range_negative_numeric_bound(collection): + """Range with negative lower bound and zero upper [-5, 0] is valid.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10.0}, + {"_id": 2, "partition": "A", "score": 3, "value": 20.0}, + {"_id": 3, "partition": "A", "score": 6, "value": 30.0}, + {"_id": 4, "partition": "A", "score": 10, "value": 40.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": [-5, 0]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # range [-5, 0]: + # Row 1 (score=1): includes scores in [1-5, 1] = [-4, 1] → only score 1 → stdDevPop([10]) = 0 + # Row 2 (score=3): includes scores in [3-5, 3] = [-2, 3] → scores 1, 3 → stdDevPop([10, 20]) = + # Row 3 (score=6): includes scores in [6-5, 6] = [1, 6] → scores 1, 3, 6 → stdDevPop([10, 20, + # Row 4 (score=10): includes scores in [10-5, 10] = [5, 10] → scores 6, 10 → stdDevPop([30, + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10.0, "result": 0}, + {"_id": 2, "partition": "A", "score": 3, "value": 20.0, "result": 5.0}, + {"_id": 3, "partition": "A", "score": 6, "value": 30.0, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "score": 10, "value": 40.0, "result": 5.0}, + ] + assertSuccess(result, expected, msg="negative range bound [-5, 0] is valid") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_sort.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_sort.py new file mode 100644 index 000000000..7b6f0872e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_sort.py @@ -0,0 +1,261 @@ +""" +Tests for $stdDevPop sort interactions in documents-mode windows. + +Covers: descending sort with cumulative window, compound/mixed-direction sort, +sort with ties and sliding frame stability, and sort on non-_id field. +""" + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_stddevpop_descending_sort_sliding(collection): + """$stdDevPop with descending sort and sliding window [-1, 0].""" + docs = [ + {"_id": 1, "partition": "A", "value": 10.0}, + {"_id": 2, "partition": "A", "value": 20.0}, + {"_id": 3, "partition": "A", "value": 30.0}, + {"_id": 4, "partition": "A", "value": 40.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": -1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": [-1, 0]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Descending sort order: _id 4, 3, 2, 1 → values 40, 30, 20, 10 + # Row _id=4: [40] → single element → 0.0 + # Row _id=3: [40, 30] → 5 + # Row _id=2: [30, 20] → 5 + # Row _id=1: [20, 10] → 5 + expected = [ + {"_id": 4, "partition": "A", "value": 40.0, "result": 0}, + {"_id": 3, "partition": "A", "value": 30.0, "result": 5.0}, + {"_id": 2, "partition": "A", "value": 20.0, "result": 5.0}, + {"_id": 1, "partition": "A", "value": 10.0, "result": 5.0}, + ] + assertSuccess(result, expected, msg="descending sort with sliding window [-1, 0]") + + +def test_stddevpop_mixed_direction_compound_sort(collection): + """$stdDevPop with mixed-direction compound sortBy {a: 1, b: -1}.""" + docs = [ + {"_id": 1, "partition": "A", "a": 1, "b": 2, "value": 10}, + {"_id": 2, "partition": "A", "a": 1, "b": 1, "value": 20}, + {"_id": 3, "partition": "A", "a": 2, "b": 2, "value": 30}, + {"_id": 4, "partition": "A", "a": 2, "b": 1, "value": 40}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"a": 1, "b": -1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Sort order: a=1,b=2 (id=1) → a=1,b=1 (id=2) → a=2,b=2 (id=3) → a=2,b=1 (id=4) + # Values in order: 10, 20, 30, 40 + # Cumulative: [10]→0, [10,20]→5, [10,20,30]→8.165, [10,20,30,40]→11.18 + expected = [ + {"_id": 1, "partition": "A", "a": 1, "b": 2, "value": 10, "result": 0.0}, + {"_id": 2, "partition": "A", "a": 1, "b": 1, "value": 20, "result": 5.0}, + {"_id": 3, "partition": "A", "a": 2, "b": 2, "value": 30, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "a": 2, "b": 1, "value": 40, "result": 11.180339887498949}, + ] + assertSuccess(result, expected, msg="mixed-direction compound sort {a:1, b:-1}") + + +def test_stddevpop_sort_with_ties_sliding_frame(collection): + """$stdDevPop with ties in sortBy field and sliding frame — stable output.""" + docs = [ + {"_id": 1, "partition": "A", "score": 10, "value": 5}, + {"_id": 2, "partition": "A", "score": 10, "value": 15}, + {"_id": 3, "partition": "A", "score": 10, "value": 25}, + {"_id": 4, "partition": "A", "score": 20, "value": 35}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1, "_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": [-1, 1]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Sort: score=10,_id=1 → score=10,_id=2 → score=10,_id=3 → score=20,_id=4 + # Values in order: 5, 15, 25, 35 + # [-1,1] windows: + # Row 1: [5, 15] → 5.0 + # Row 2: [5, 15, 25] → 8.165 + # Row 3: [15, 25, 35] → 8.165 + # Row 4: [25, 35] → 5.0 + expected = [ + {"_id": 1, "partition": "A", "score": 10, "value": 5, "result": 5.0}, + {"_id": 2, "partition": "A", "score": 10, "value": 15, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "score": 10, "value": 25, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "score": 20, "value": 35, "result": 5.0}, + ] + assertSuccess(result, expected, msg="sort with ties + compound tiebreaker + sliding frame") + + +def test_stddevpop_sort_on_non_id_field(collection): + """$stdDevPop with sortBy on a non-_id field.""" + docs = [ + {"_id": 3, "partition": "A", "ts": 100, "value": 30}, + {"_id": 1, "partition": "A", "ts": 200, "value": 10}, + {"_id": 2, "partition": "A", "ts": 300, "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"ts": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Sort by ts: id=3(ts=100), id=1(ts=200), id=2(ts=300) → values 30, 10, 20 + # Cumulative: [30]→0, [30,10]→10, [30,10,20]→8.165 + expected = [ + {"_id": 3, "partition": "A", "ts": 100, "value": 30, "result": 0.0}, + {"_id": 1, "partition": "A", "ts": 200, "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "ts": 300, "value": 20, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="sortBy on non-_id field (ts)") + + +def test_stddevpop_descending_sort_reverse_cumulative(collection): + """$stdDevPop descending sort with reverse-cumulative [current, unbounded].""" + docs = [ + {"_id": 1, "partition": "A", "value": 10.0}, + {"_id": 2, "partition": "A", "value": 20.0}, + {"_id": 3, "partition": "A", "value": 30.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": -1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["current", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Descending sort: _id 3, 2, 1 → values 30, 20, 10 + # [current, unbounded]: + # Row id=3: [30, 20, 10] → 8.165 + # Row id=2: [20, 10] → 5.0 + # Row id=1: [10] → single element → 0.0 + expected = [ + {"_id": 3, "partition": "A", "value": 30.0, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": 20.0, "result": 5.0}, + {"_id": 1, "partition": "A", "value": 10.0, "result": 0}, + ] + assertSuccess(result, expected, msg="descending sort + reverse cumulative") + + +def test_stddevpop_sort_by_value_field_same_as_expression(collection): + """$stdDevPop where sortBy field is the same field as the expression.""" + docs = [ + {"_id": 1, "partition": "A", "value": 30}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"value": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Sort by value: 10(id=2), 20(id=3), 30(id=1) + # Cumulative: [10]→0, [10,20]→5, [10,20,30]→8.165 + expected = [ + {"_id": 2, "partition": "A", "value": 10, "result": 0.0}, + {"_id": 3, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 1, "partition": "A", "value": 30, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="sortBy same field as expression field") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_special_floats.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_special_floats.py new file mode 100644 index 000000000..dc93876d5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_special_floats.py @@ -0,0 +1,263 @@ +""" +Tests for $stdDevPop with special float values (NaN, Infinity, -Infinity). + +Covers: Infinity as numeric participant, -Infinity, NaN values, +sliding window behavior with special floats, and non-removable window +behavior (NaN propagation). + +DocumentDB behavior (consistent IEEE 754): NaN/Inf are numeric and propagate NaN +uniformly — same result regardless of window type (removable or non-removable). + +The reference server has inconsistent behavior: +- Non-removable windows (unbounded, cumulative): NaN/Inf → result is NaN +- Removable/sliding windows: NaN/Inf in frame → result is null (not NaN) +- $group: NaN → result is null +This is three different answers for the same semantic question. DocumentDB's +uniform NaN propagation is the more correct IEEE 754 interpretation. +""" + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessNaN +from documentdb_tests.framework.executor import execute_command + + +def _run_stddevpop(collection, docs, window, sort_by=None, partition_by="$partition"): + """Helper to run $stdDevPop and return the raw command result.""" + collection.insert_many(docs) + pipeline = [ + { + "$setWindowFields": { + "partitionBy": partition_by, + "sortBy": sort_by or {"_id": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": window, + } + }, + } + } + ] + return execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + + +def test_stddevpop_positive_infinity_whole_partition(collection): + """$stdDevPop with Infinity in non-removable whole partition window produces NaN.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": float("inf")}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": float("nan")}, + {"_id": 2, "partition": "A", "value": float("inf"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": 30, "result": float("nan")}, + ] + assertSuccessNaN(result, expected, msg="Infinity is numeric; non-removable window produces NaN") + + +def test_stddevpop_negative_infinity_whole_partition(collection): + """$stdDevPop with -Infinity in non-removable whole partition window produces NaN.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": float("-inf")}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": float("nan")}, + {"_id": 2, "partition": "A", "value": float("-inf"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": 30, "result": float("nan")}, + ] + assertSuccessNaN( + result, expected, msg="-Infinity is numeric; non-removable window produces NaN" + ) + + +def test_stddevpop_inf_and_neg_inf_in_same_frame(collection): + """$stdDevPop with both Infinity and -Infinity produces NaN (Inf - Inf = NaN in variance).""" + docs = [ + {"_id": 1, "partition": "A", "value": float("inf")}, + {"_id": 2, "partition": "A", "value": float("-inf")}, + {"_id": 3, "partition": "A", "value": 10}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": float("inf"), "result": float("nan")}, + {"_id": 2, "partition": "A", "value": float("-inf"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": 10, "result": float("nan")}, + ] + assertSuccessNaN(result, expected, msg="Inf + -Inf in frame → NaN in variance calculation") + + +def test_stddevpop_nan_value_whole_partition(collection): + """$stdDevPop with NaN in non-removable window produces NaN (NaN is numeric, poisons).""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": float("nan")}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": float("nan")}, + {"_id": 2, "partition": "A", "value": float("nan"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": 30, "result": float("nan")}, + ] + assertSuccessNaN(result, expected, msg="NaN is numeric; non-removable window produces NaN") + + +def test_stddevpop_all_infinity_values(collection): + """$stdDevPop where all values are Infinity — identical Inf: mean=Inf, Inf-Inf=NaN.""" + docs = [ + {"_id": 1, "partition": "A", "value": float("inf")}, + {"_id": 2, "partition": "A", "value": float("inf")}, + {"_id": 3, "partition": "A", "value": float("inf")}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": float("inf"), "result": float("nan")}, + {"_id": 2, "partition": "A", "value": float("inf"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": float("inf"), "result": float("nan")}, + ] + assertSuccessNaN( + result, expected, msg="All identical Inf: mean=Inf, variance involves Inf-Inf=NaN" + ) + + +def test_stddevpop_infinity_cumulative_window(collection): + """$stdDevPop with Infinity in cumulative [unbounded, current] produces NaN.""" + docs = [ + {"_id": 1, "partition": "A", "value": float("inf")}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "current"]}) + expected = [ + {"_id": 1, "partition": "A", "value": float("inf"), "result": float("nan")}, + {"_id": 2, "partition": "A", "value": 10, "result": float("nan")}, + {"_id": 3, "partition": "A", "value": 30, "result": float("nan")}, + ] + assertSuccessNaN( + result, expected, msg="Cumulative window is non-removable; Inf produces NaN throughout" + ) + + +def test_stddevpop_infinity_sliding_returns_null(collection): + """$stdDevPop sliding window returns null when Infinity is in the current frame.""" + docs = [ + {"_id": 1, "partition": "A", "value": float("inf")}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": 20}, + {"_id": 4, "partition": "A", "value": 30}, + {"_id": 5, "partition": "A", "value": 40}, + ] + result = _run_stddevpop(collection, docs, {"documents": [-1, 0]}) + # Row 1: frame=[Inf] -> null (Inf in removable window) + # Row 2: frame=[Inf, 10] -> null (Inf in removable window) + # Row 3: frame=[10, 20] -> clean -> 5.0 + # Row 4: frame=[20, 30] -> clean -> 5.0 + # Row 5: frame=[30, 40] -> clean -> 5.0 + expected = [ + {"_id": 1, "partition": "A", "value": float("inf"), "result": None}, + {"_id": 2, "partition": "A", "value": 10, "result": None}, + {"_id": 3, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 4, "partition": "A", "value": 30, "result": 5.0}, + {"_id": 5, "partition": "A", "value": 40, "result": 5.0}, + ] + assertSuccess( + result, expected, msg="Sliding window: null when Inf in frame, recovers when Inf leaves" + ) + + +def test_stddevpop_neg_infinity_sliding_returns_null(collection): + """$stdDevPop sliding window returns null when -Infinity is in the current frame.""" + docs = [ + {"_id": 1, "partition": "A", "value": float("-inf")}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": 20}, + {"_id": 4, "partition": "A", "value": 30}, + ] + result = _run_stddevpop(collection, docs, {"documents": [-1, 0]}) + # Row 1: frame=[-Inf] -> null (Inf in removable window) + # Row 2: frame=[-Inf, 10] -> null (Inf in removable window) + # Row 3: frame=[10, 20] -> 5.0 + # Row 4: frame=[20, 30] -> 5.0 + expected = [ + {"_id": 1, "partition": "A", "value": float("-inf"), "result": None}, + {"_id": 2, "partition": "A", "value": 10, "result": None}, + {"_id": 3, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 4, "partition": "A", "value": 30, "result": 5.0}, + ] + assertSuccess( + result, expected, msg="Sliding window: null when -Inf in frame, recovers when -Inf leaves" + ) + + +def test_stddevpop_nan_sliding_returns_null(collection): + """$stdDevPop sliding window returns null when NaN is in the current frame.""" + docs = [ + {"_id": 1, "partition": "A", "value": float("nan")}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": 20}, + {"_id": 4, "partition": "A", "value": 30}, + ] + result = _run_stddevpop(collection, docs, {"documents": [-1, 0]}) + # Row 1: frame=[NaN] -> null (NaN in removable window) + # Row 2: frame=[NaN, 10] -> null (NaN in removable window) + # Row 3: frame=[10, 20] -> 5.0 + # Row 4: frame=[20, 30] -> 5.0 + expected = [ + {"_id": 1, "partition": "A", "value": float("nan"), "result": None}, + {"_id": 2, "partition": "A", "value": 10, "result": None}, + {"_id": 3, "partition": "A", "value": 20, "result": 5.0}, + {"_id": 4, "partition": "A", "value": 30, "result": 5.0}, + ] + assertSuccessNaN( + result, expected, msg="Sliding window: null when NaN in frame, recovers when NaN leaves" + ) + + +def test_stddevpop_infinity_centered_sliding(collection): + """$stdDevPop centered sliding window [-1, 1] with Infinity in middle returns null.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": float("inf")}, + {"_id": 4, "partition": "A", "value": 30}, + {"_id": 5, "partition": "A", "value": 40}, + ] + result = _run_stddevpop(collection, docs, {"documents": [-1, 1]}) + # Row 1: frame=[10, 20] -> 5.0 (clean, edge clamp) + # Row 2: frame=[10, 20, Inf] -> null (Inf in removable window) + # Row 3: frame=[20, Inf, 30] -> null (Inf in removable window) + # Row 4: frame=[Inf, 30, 40] -> null (Inf in removable window) + # Row 5: frame=[30, 40] -> 5.0 (clean, edge clamp) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 5.0}, + {"_id": 2, "partition": "A", "value": 20, "result": None}, + {"_id": 3, "partition": "A", "value": float("inf"), "result": None}, + {"_id": 4, "partition": "A", "value": 30, "result": None}, + {"_id": 5, "partition": "A", "value": 40, "result": 5.0}, + ] + assertSuccess( + result, + expected, + msg="Centered sliding: null when Inf in frame, clean rows compute correctly", + ) + + +def test_stddevpop_single_infinity_value(collection): + """$stdDevPop with single Infinity value in whole partition produces NaN.""" + docs = [ + {"_id": 1, "partition": "A", "value": float("inf")}, + ] + result = _run_stddevpop(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": float("inf"), "result": float("nan")}, + ] + assertSuccessNaN( + result, expected, msg="Single Inf value: stdDevPop involves Inf arithmetic → NaN" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_time_range_mode_frames.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_time_range_mode_frames.py new file mode 100644 index 000000000..8e3c2b942 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevPop/test_window_stdDevPop_time_range_mode_frames.py @@ -0,0 +1,607 @@ +""" +Tests for $stdDevPop with time-range windows covering all 9 time units. + +Covers: year, quarter, month, week, day, hour, minute, second, millisecond +time units with sliding and unbounded/current combinations. +Also tests gap-in-dates exclusion from frame. + +Note: time-range windows with unit=day and unit=hour are already tested in +test_window_stddevpop_range.py. This file covers the remaining units and +additional time-range patterns. +""" + +from datetime import datetime, timezone + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def _run_time_range(collection, docs, range_bounds, unit): + """Helper to run $stdDevPop with time-range window.""" + collection.insert_many(docs) + pipeline = [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevPop": "$value", + "window": {"range": range_bounds, "unit": unit}, + } + }, + } + } + ] + return execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + + +def test_stddevpop_time_range_year_sliding(collection): + """$stdDevPop with time-range unit=year, sliding [-1, 1].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2020, 6, 1, tzinfo=timezone.utc), + "value": 10.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2021, 6, 1, tzinfo=timezone.utc), + "value": 20.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2022, 6, 1, tzinfo=timezone.utc), + "value": 30.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 6, 1, tzinfo=timezone.utc), + "value": 40.0, + }, + ] + result = _run_time_range(collection, docs, [-1, 1], "year") + # year sliding [-1, 1] (symmetric, all consecutive): + # Row 1 (2020): [2019-06-01, 2021-06-01] → includes 2020, 2021 → [10, 20] → 5.0 + # Row 2 (2021): [2020-06-01, 2022-06-01] → includes 2020, 2021, 2022 → [10, 20, 30] → 8.165 + # Row 3 (2022): [2021-06-01, 2023-06-01] → includes 2021, 2022, 2023 → [20, 30, 40] → 8.165 + # Row 4 (2023): [2022-06-01, 2024-06-01] → includes 2022, 2023 → [30, 40] → 5.0 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2020, 6, 1, tzinfo=timezone.utc), + "value": 10.0, + "result": 5.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2021, 6, 1, tzinfo=timezone.utc), + "value": 20.0, + "result": 8.16496580927726, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2022, 6, 1, tzinfo=timezone.utc), + "value": 30.0, + "result": 8.16496580927726, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 6, 1, tzinfo=timezone.utc), + "value": 40.0, + "result": 5.0, + }, + ] + assertSuccess(result, expected, msg="time-range unit=year sliding [-1, 1]") + + +def test_stddevpop_time_range_quarter_sliding(collection): + """$stdDevPop with time-range unit=quarter, sliding [-1, 1].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 4, 15, tzinfo=timezone.utc), + "value": 20.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 7, 15, tzinfo=timezone.utc), + "value": 30.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 10, 15, tzinfo=timezone.utc), + "value": 40.0, + }, + ] + result = _run_time_range(collection, docs, [-1, 1], "quarter") + # quarter sliding [-1, 1] (symmetric, consecutive quarters): + # Row 1 (Q1 Jan): frame covers [Oct'22, Jul'23] → includes Q1, Q2 → [10, 20] → 5.0 + # Row 2 (Q2 Apr): frame covers [Jan'23, Oct'23] → includes Q1, Q2, Q3 → [10, 20, 30] → 8.165 + # Row 3 (Q3 Jul): frame covers [Apr'23, Jan'24] → includes Q2, Q3, Q4 → [20, 30, 40] → 8.165 + # Row 4 (Q4 Oct): frame covers [Jul'23, Apr'24] → includes Q3, Q4 → [30, 40] → 5.0 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10.0, + "result": 5.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 4, 15, tzinfo=timezone.utc), + "value": 20.0, + "result": 8.16496580927726, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 7, 15, tzinfo=timezone.utc), + "value": 30.0, + "result": 8.16496580927726, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 10, 15, tzinfo=timezone.utc), + "value": 40.0, + "result": 5.0, + }, + ] + assertSuccess(result, expected, msg="time-range unit=quarter sliding [-1, 1]") + + +def test_stddevpop_time_range_month_sliding(collection): + """$stdDevPop with time-range unit=month, sliding [-1, 1].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 15, tzinfo=timezone.utc), + "value": 20.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 15, tzinfo=timezone.utc), + "value": 30.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 4, 15, tzinfo=timezone.utc), + "value": 40.0, + }, + ] + result = _run_time_range(collection, docs, [-1, 1], "month") + # month sliding [-1, 1] (symmetric, consecutive months): + # Row 1 (Jan): [Dec'22, Feb'23] → includes Jan, Feb → [10, 20] → 5.0 + # Row 2 (Feb): [Jan, Mar] → includes Jan, Feb, Mar → [10, 20, 30] → 8.165 + # Row 3 (Mar): [Feb, Apr] → includes Feb, Mar, Apr → [20, 30, 40] → 8.165 + # Row 4 (Apr): [Mar, May] → includes Mar, Apr → [30, 40] → 5.0 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10.0, + "result": 5.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 15, tzinfo=timezone.utc), + "value": 20.0, + "result": 8.16496580927726, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 15, tzinfo=timezone.utc), + "value": 30.0, + "result": 8.16496580927726, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 4, 15, tzinfo=timezone.utc), + "value": 40.0, + "result": 5.0, + }, + ] + assertSuccess(result, expected, msg="time-range unit=month sliding [-1, 1]") + + +def test_stddevpop_time_range_minute_sliding(collection): + """$stdDevPop with time-range unit=minute, sliding [-3, 3].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 0, 0, tzinfo=timezone.utc), + "value": 10.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 3, 0, tzinfo=timezone.utc), + "value": 20.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 6, 0, tzinfo=timezone.utc), + "value": 30.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 9, 0, tzinfo=timezone.utc), + "value": 40.0, + }, + ] + result = _run_time_range(collection, docs, [-3, 3], "minute") + # minute sliding [-3, 3] (symmetric, 3 min apart): + # Row 1 (10:00): [9:57, 10:03] → includes 10:00, 10:03 → [10, 20] → 5.0 + # Row 2 (10:03): [10:00, 10:06] → includes 10:00, 10:03, 10:06 → [10, 20, 30] → 8.165 + # Row 3 (10:06): [10:03, 10:09] → includes 10:03, 10:06, 10:09 → [20, 30, 40] → 8.165 + # Row 4 (10:09): [10:06, 10:12] → includes 10:06, 10:09 → [30, 40] → 5.0 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 0, 0, tzinfo=timezone.utc), + "value": 10.0, + "result": 5.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 3, 0, tzinfo=timezone.utc), + "value": 20.0, + "result": 8.16496580927726, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 6, 0, tzinfo=timezone.utc), + "value": 30.0, + "result": 8.16496580927726, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 9, 0, tzinfo=timezone.utc), + "value": 40.0, + "result": 5.0, + }, + ] + assertSuccess(result, expected, msg="time-range unit=minute sliding [-3, 3]") + + +def test_stddevpop_time_range_second_sliding(collection): + """$stdDevPop with time-range unit=second, sliding [-5, 5].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "value": 10.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 5, tzinfo=timezone.utc), + "value": 20.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 10, tzinfo=timezone.utc), + "value": 30.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 15, tzinfo=timezone.utc), + "value": 40.0, + }, + ] + result = _run_time_range(collection, docs, [-5, 5], "second") + # second sliding [-5, 5] (symmetric, 5s apart): + # Row 1 (0s): [-5s, 5s] → includes 0s, 5s → [10, 20] → 5.0 + # Row 2 (5s): [0s, 10s] → includes 0s, 5s, 10s → [10, 20, 30] → 8.165 + # Row 3 (10s): [5s, 15s] → includes 5s, 10s, 15s → [20, 30, 40] → 8.165 + # Row 4 (15s): [10s, 20s] → includes 10s, 15s → [30, 40] → 5.0 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "value": 10.0, + "result": 5.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 5, tzinfo=timezone.utc), + "value": 20.0, + "result": 8.16496580927726, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 10, tzinfo=timezone.utc), + "value": 30.0, + "result": 8.16496580927726, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 15, tzinfo=timezone.utc), + "value": 40.0, + "result": 5.0, + }, + ] + assertSuccess(result, expected, msg="time-range unit=second sliding [-5, 5]") + + +def test_stddevpop_time_range_millisecond_sliding(collection): + """$stdDevPop with time-range unit=millisecond, sliding [-50, 50].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc), + "value": 10.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 50000, tzinfo=timezone.utc), + "value": 20.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 100000, tzinfo=timezone.utc), + "value": 30.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 150000, tzinfo=timezone.utc), + "value": 40.0, + }, + ] + result = _run_time_range(collection, docs, [-50, 50], "millisecond") + # millisecond sliding [-50, 50] (symmetric, 50ms apart): + # Doc 1: 0ms, Doc 2: 50ms, Doc 3: 100ms, Doc 4: 150ms + # Row 1 (0ms): [-50ms, 50ms] → includes 0ms, 50ms → [10, 20] → 5.0 + # Row 2 (50ms): [0ms, 100ms] → includes 0ms, 50ms, 100ms → [10, 20, 30] → 8.165 + # Row 3 (100ms): [50ms, 150ms] → includes 50ms, 100ms, 150ms → [20, 30, 40] → 8.165 + # Row 4 (150ms): [100ms, 200ms] → includes 100ms, 150ms → [30, 40] → 5.0 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc), + "value": 10.0, + "result": 5.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 50000, tzinfo=timezone.utc), + "value": 20.0, + "result": 8.16496580927726, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 100000, tzinfo=timezone.utc), + "value": 30.0, + "result": 8.16496580927726, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 150000, tzinfo=timezone.utc), + "value": 40.0, + "result": 5.0, + }, + ] + assertSuccess(result, expected, msg="time-range unit=millisecond sliding [-50, 50]") + + +def test_stddevpop_time_range_gap_excludes_distant_docs(collection): + """$stdDevPop time-range with gap in dates excludes distant documents.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 10, tzinfo=timezone.utc), + "value": 30, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 11, tzinfo=timezone.utc), + "value": 40, + }, + ] + result = _run_time_range(collection, docs, [-2, 2], "day") + # day sliding [-2, 2]: + # Row 1 (Jan 1): [Jan 1, Jan 2] → 5.0 (Jan 10 is 9 days away) + # Row 2 (Jan 2): [Jan 1, Jan 2] → 5.0 + # Row 3 (Jan 10): [Jan 10, Jan 11] → 5.0 (Jan 2 is 8 days away) + # Row 4 (Jan 11): [Jan 10, Jan 11] → 5.0 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + "result": 5.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20, + "result": 5.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 10, tzinfo=timezone.utc), + "value": 30, + "result": 5.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 11, tzinfo=timezone.utc), + "value": 40, + "result": 5.0, + }, + ] + assertSuccess(result, expected, msg="time-range gap excludes distant docs") + + +def test_stddevpop_time_range_current_unbounded_with_unit(collection): + """$stdDevPop time-range [current, unbounded] with unit=day.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 3, tzinfo=timezone.utc), + "value": 30, + }, + ] + result = _run_time_range(collection, docs, ["current", "unbounded"], "day") + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + "result": 8.16496580927726, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20, + "result": 5.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 3, tzinfo=timezone.utc), + "value": 30, + "result": 0, + }, + ] + assertSuccess(result, expected, msg="time-range [current, unbounded] unit=day") + + +def test_stddevpop_time_range_year_unbounded_current(collection): + """$stdDevPop time-range [unbounded, current] with unit=year.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2020, 6, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2021, 6, 1, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2022, 6, 1, tzinfo=timezone.utc), + "value": 30, + }, + ] + result = _run_time_range(collection, docs, ["unbounded", "current"], "year") + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2020, 6, 1, tzinfo=timezone.utc), + "value": 10, + "result": 0.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2021, 6, 1, tzinfo=timezone.utc), + "value": 20, + "result": 5.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2022, 6, 1, tzinfo=timezone.utc), + "value": 30, + "result": 8.16496580927726, + }, + ] + assertSuccess(result, expected, msg="time-range [unbounded, current] unit=year") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_documents_mode_frames.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_documents_mode_frames.py new file mode 100644 index 000000000..9f3c17ff7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_documents_mode_frames.py @@ -0,0 +1,423 @@ +""" +Tests for $stdDevSamp with documents-mode window frames. + +Covers all frame shapes: cumulative, reverse-cumulative, sliding, centered, +leading, trailing, both-before, both-after, wider-than-partition, +and whole partition (unbounded-unbounded). +""" + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def _run_stdDevSamp(collection, docs, window, sort_by=None, extra_stages=None): + """Helper to run a $stdDevSamp pipeline with given window.""" + collection.insert_many(docs) + pipeline = [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": sort_by or {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": window, + } + }, + } + } + ] + if extra_stages: + pipeline.extend(extra_stages) + return execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + + +BASIC_DOCS = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + {"_id": 4, "partition": "A", "value": 40}, + {"_id": 5, "partition": "A", "value": 50}, +] + + +def test_stdDevSamp_whole_partition(collection): + """$stdDevSamp over unbounded-unbounded returns same value for all rows.""" + result = _run_stdDevSamp(collection, BASIC_DOCS, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 15.811388300841896}, + {"_id": 2, "partition": "A", "value": 20, "result": 15.811388300841896}, + {"_id": 3, "partition": "A", "value": 30, "result": 15.811388300841896}, + {"_id": 4, "partition": "A", "value": 40, "result": 15.811388300841896}, + {"_id": 5, "partition": "A", "value": 50, "result": 15.811388300841896}, + ] + assertSuccess(result, expected, msg="whole partition stdDevSamp = sqrt(250)") + + +def test_stdDevSamp_cumulative(collection): + """$stdDevSamp cumulative window grows with each row; row 1 is null (N=1).""" + result = _run_stdDevSamp( + collection, + BASIC_DOCS, + {"documents": ["unbounded", "current"]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": None}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + {"_id": 4, "partition": "A", "value": 40, "result": 12.909944487358056}, + {"_id": 5, "partition": "A", "value": 50, "result": 15.811388300841896}, + ] + assertSuccess(result, expected, msg="cumulative stdDevSamp grows") + + +def test_stdDevSamp_reverse_cumulative(collection): + """$stdDevSamp reverse-cumulative window shrinks with each row; last row is null.""" + result = _run_stdDevSamp( + collection, + BASIC_DOCS, + {"documents": ["current", "unbounded"]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 15.811388300841896}, + {"_id": 2, "partition": "A", "value": 20, "result": 12.909944487358056}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + {"_id": 4, "partition": "A", "value": 40, "result": 7.0710678118654755}, + {"_id": 5, "partition": "A", "value": 50, "result": None}, + ] + assertSuccess(result, expected, msg="reverse-cumulative stdDevSamp shrinks") + + +def test_stdDevSamp_sliding_centered(collection): + """$stdDevSamp centered sliding window [-1, 1].""" + result = _run_stdDevSamp(collection, BASIC_DOCS, {"documents": [-1, 1]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + {"_id": 4, "partition": "A", "value": 40, "result": 10.0}, + {"_id": 5, "partition": "A", "value": 50, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="centered sliding window [-1,1]") + + +def test_stdDevSamp_trailing(collection): + """$stdDevSamp trailing window [-1, 0]; row 1 is null (N=1).""" + result = _run_stdDevSamp( + collection, + BASIC_DOCS, + {"documents": [-1, 0]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": None}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": 30, "result": 7.0710678118654755}, + {"_id": 4, "partition": "A", "value": 40, "result": 7.0710678118654755}, + {"_id": 5, "partition": "A", "value": 50, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="trailing window [-1,0]") + + +def test_stdDevSamp_leading(collection): + """$stdDevSamp leading window [0, 1]; last row is null (N=1).""" + result = _run_stdDevSamp( + collection, + BASIC_DOCS, + {"documents": [0, 1]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": 30, "result": 7.0710678118654755}, + {"_id": 4, "partition": "A", "value": 40, "result": 7.0710678118654755}, + {"_id": 5, "partition": "A", "value": 50, "result": None}, + ] + assertSuccess(result, expected, msg="leading window [0,1]") + + +def test_stdDevSamp_both_before(collection): + """$stdDevSamp both-before window [-2, -1]; rows 1-2 are null/empty.""" + result = _run_stdDevSamp( + collection, + BASIC_DOCS, + {"documents": [-2, -1]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": None}, + {"_id": 2, "partition": "A", "value": 20, "result": None}, + {"_id": 3, "partition": "A", "value": 30, "result": 7.0710678118654755}, + {"_id": 4, "partition": "A", "value": 40, "result": 7.0710678118654755}, + {"_id": 5, "partition": "A", "value": 50, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="both-before window [-2,-1]") + + +def test_stdDevSamp_both_after(collection): + """$stdDevSamp both-after window [1, 2]; rows 4-5 are null/empty.""" + result = _run_stdDevSamp( + collection, + BASIC_DOCS, + {"documents": [1, 2]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": 30, "result": 7.0710678118654755}, + {"_id": 4, "partition": "A", "value": 40, "result": None}, + {"_id": 5, "partition": "A", "value": 50, "result": None}, + ] + assertSuccess(result, expected, msg="both-after window [1,2]") + + +def test_stdDevSamp_wider_than_partition(collection): + """$stdDevSamp window wider than partition [-10, 10] clamps to partition edges.""" + result = _run_stdDevSamp(collection, BASIC_DOCS, {"documents": [-10, 10]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 15.811388300841896}, + {"_id": 2, "partition": "A", "value": 20, "result": 15.811388300841896}, + {"_id": 3, "partition": "A", "value": 30, "result": 15.811388300841896}, + {"_id": 4, "partition": "A", "value": 40, "result": 15.811388300841896}, + {"_id": 5, "partition": "A", "value": 50, "result": 15.811388300841896}, + ] + assertSuccess(result, expected, msg="wider-than-partition clamps to edges") + + +def test_stdDevSamp_empty_frame_returns_null(collection): + """$stdDevSamp on an empty frame returns null.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + ] + result = _run_stdDevSamp(collection, docs, {"documents": [-5, -4]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": None}, + {"_id": 2, "partition": "A", "value": 20, "result": None}, + ] + assertSuccess(result, expected, msg="empty frame returns null") + + +def test_stdDevSamp_default_window_no_window_key(collection): + """$stdDevSamp with no explicit window key defaults to whole partition.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": {"$stdDevSamp": "$value"}, + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="no window key defaults to whole partition") + + +def test_stdDevSamp_sliding_3_doc_window(collection): + """$stdDevSamp sliding window [-2, 0] covering up to 3 docs.""" + result = _run_stdDevSamp( + collection, + BASIC_DOCS, + {"documents": [-2, 0]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": None}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + {"_id": 4, "partition": "A", "value": 40, "result": 10.0}, + {"_id": 5, "partition": "A", "value": 50, "result": 10.0}, + ] + assertSuccess(result, expected, msg="sliding [-2,0] window") + + +def test_stdDevSamp_mixed_unbounded_and_int(collection): + """$stdDevSamp with mixed unbounded lower and integer upper ["unbounded", 1].""" + result = _run_stdDevSamp(collection, BASIC_DOCS, {"documents": ["unbounded", 1]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 12.909944487358056}, + {"_id": 4, "partition": "A", "value": 40, "result": 15.811388300841896}, + {"_id": 5, "partition": "A", "value": 50, "result": 15.811388300841896}, + ] + assertSuccess(result, expected, msg="mixed [unbounded, 1] window") + + +def test_stdDevSamp_int_lower_to_current(collection): + """$stdDevSamp with integer lower and 'current' upper [-5, "current"].""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stdDevSamp( + collection, + docs, + {"documents": [-5, "current"]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": None}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="[-5, current] clamps to partition start") + + +def test_stdDevSamp_two_docs_partition(collection): + """$stdDevSamp with exactly 2 documents in partition.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 30}, + ] + result = _run_stdDevSamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "value": 30, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="two-doc partition computes correctly") + + +def test_stdDevSamp_all_zeros(collection): + """$stdDevSamp where all values are zero returns 0.""" + docs = [ + {"_id": 1, "partition": "A", "value": 0}, + {"_id": 2, "partition": "A", "value": 0}, + {"_id": 3, "partition": "A", "value": 0}, + ] + result = _run_stdDevSamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 0, "result": 0.0}, + {"_id": 2, "partition": "A", "value": 0, "result": 0.0}, + {"_id": 3, "partition": "A", "value": 0, "result": 0.0}, + ] + assertSuccess(result, expected, msg="all zeros return 0") + + +def test_stdDevSamp_mix_positive_negative_zero(collection): + """$stdDevSamp with positive, negative, and zero values.""" + docs = [ + {"_id": 1, "partition": "A", "value": -10}, + {"_id": 2, "partition": "A", "value": 0}, + {"_id": 3, "partition": "A", "value": 10}, + {"_id": 4, "partition": "A", "value": 0}, + ] + result = _run_stdDevSamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": -10, "result": 8.16496580927726}, + {"_id": 2, "partition": "A", "value": 0, "result": 8.16496580927726}, + {"_id": 3, "partition": "A", "value": 10, "result": 8.16496580927726}, + {"_id": 4, "partition": "A", "value": 0, "result": 8.16496580927726}, + ] + assertSuccess(result, expected, msg="mixed positive, negative, and zero") + + +def test_stdDevSamp_forward_looking_sliding(collection): + """$stdDevSamp forward-looking sliding window [1, 2].""" + result = _run_stdDevSamp( + collection, + BASIC_DOCS, + {"documents": [1, 2]}, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": 30, "result": 7.0710678118654755}, + {"_id": 4, "partition": "A", "value": 40, "result": None}, + {"_id": 5, "partition": "A", "value": 50, "result": None}, + ] + assertSuccess(result, expected, msg="forward-looking window [1,2] sliding") + + +def test_stddevsamp_empty_window_object(collection): + """$stdDevSamp with empty window object {} is equivalent to no window (whole partition).""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="empty window {} = whole partition") + + +def test_stddevsamp_no_sortby_with_documents_window(collection): + """$stdDevSamp with documents window but no sortBy — unbounded still works.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess( + result, expected, ignore_doc_order=True, msg="no sortBy with documents unbounded window" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_field_paths.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_field_paths.py new file mode 100644 index 000000000..62a87b1fa --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_field_paths.py @@ -0,0 +1,465 @@ +""" +Tests for $stdDevSamp with nested field paths, array field traversal, +expressions that return different types per document, and $unset/$project +removing fields before $setWindowFields. +""" + +from datetime import datetime, timezone + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_stddevsamp_dotted_field_path(collection): + """$stdDevSamp with dotted field path accesses nested document value.""" + docs = [ + {"_id": 1, "partition": "A", "data": {"metrics": {"value": 10}}}, + {"_id": 2, "partition": "A", "data": {"metrics": {"value": 20}}}, + {"_id": 3, "partition": "A", "data": {"metrics": {"value": 30}}}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$data.metrics.value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # stdDevSamp([10, 20, 30]) = 10.0 + expected = [ + {"_id": 1, "partition": "A", "data": {"metrics": {"value": 10}}, "result": 10.0}, + {"_id": 2, "partition": "A", "data": {"metrics": {"value": 20}}, "result": 10.0}, + {"_id": 3, "partition": "A", "data": {"metrics": {"value": 30}}, "result": 10.0}, + ] + assertSuccess(result, expected, msg="dotted field path accesses nested value") + + +def test_stddevsamp_missing_intermediate_path(collection): + """$stdDevSamp with missing intermediate path treated as missing (ignored).""" + docs = [ + {"_id": 1, "partition": "A", "data": {"metrics": {"value": 10}}}, + {"_id": 2, "partition": "A", "data": {"other": 99}}, + {"_id": 3, "partition": "A", "data": {"metrics": {"value": 30}}}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$data.metrics.value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Doc 2 missing data.metrics.value -> ignored. stdDevSamp([10, 30]) = 14.142135623730951 + expected = [ + { + "_id": 1, + "partition": "A", + "data": {"metrics": {"value": 10}}, + "result": 14.142135623730951, + }, + {"_id": 2, "partition": "A", "data": {"other": 99}, "result": 14.142135623730951}, + { + "_id": 3, + "partition": "A", + "data": {"metrics": {"value": 30}}, + "result": 14.142135623730951, + }, + ] + assertSuccess(result, expected, msg="missing intermediate path treated as missing") + + +def test_stddevsamp_top_level_missing_object(collection): + """$stdDevSamp where the top-level field of a dotted path is missing.""" + docs = [ + {"_id": 1, "partition": "A", "data": {"value": 10}}, + {"_id": 2, "partition": "A"}, + {"_id": 3, "partition": "A", "data": {"value": 30}}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$data.value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Doc 2 missing "data" entirely -> ignored. stdDevSamp([10, 30]) = 14.142135623730951 + expected = [ + {"_id": 1, "partition": "A", "data": {"value": 10}, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "result": 14.142135623730951}, + {"_id": 3, "partition": "A", "data": {"value": 30}, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="top-level field missing in dotted path = ignored") + + +def test_stddevsamp_array_field_is_non_numeric(collection): + """$stdDevSamp on a top-level array field — arrays are non-numeric, should be ignored.""" + docs = [ + {"_id": 1, "partition": "A", "value": [10, 20, 30]}, + {"_id": 2, "partition": "A", "value": 50}, + {"_id": 3, "partition": "A", "value": [40, 50]}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Only doc 2 has a numeric value. Single numeric value -> stdDevSamp = null (N=1, N-1=0) + expected = [ + {"_id": 1, "partition": "A", "value": [10, 20, 30], "result": None}, + {"_id": 2, "partition": "A", "value": 50, "result": None}, + {"_id": 3, "partition": "A", "value": [40, 50], "result": None}, + ] + assertSuccess( + result, expected, msg="array field values are non-numeric — only 1 numeric gives null" + ) + + +def test_stddevsamp_expression_returns_different_types(collection): + """$stdDevSamp expression returning different types per row — non-numeric ignored.""" + docs = [ + {"_id": 1, "partition": "A", "x": 10}, + {"_id": 2, "partition": "A", "x": -5}, + {"_id": 3, "partition": "A", "x": 30}, + {"_id": 4, "partition": "A", "x": -1}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": { + "$cond": [ + {"$gt": ["$x", 0]}, + "$x", + "not_a_number", + ] + }, + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # $cond returns: 10, "not_a_number", 30, "not_a_number" + # Only numeric: [10, 30] -> stdDevSamp = 14.142135623730951 + expected = [ + {"_id": 1, "partition": "A", "x": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "x": -5, "result": 14.142135623730951}, + {"_id": 3, "partition": "A", "x": 30, "result": 14.142135623730951}, + {"_id": 4, "partition": "A", "x": -1, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="expression returning mixed types — non-numeric ignored") + + +def test_stddevsamp_expression_returns_null_for_some(collection): + """$stdDevSamp expression returning null for some docs, number for others.""" + docs = [ + {"_id": 1, "partition": "A", "x": 10, "y": 2}, + {"_id": 2, "partition": "A", "x": 20, "y": None}, + {"_id": 3, "partition": "A", "x": 30, "y": 2}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": {"$multiply": ["$x", "$y"]}, + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # $multiply: [10*2=20, 20*null=null, 30*2=60] + # Numeric values: [20, 60] -> stdDevSamp = sqrt(((20-40)^2+(60-40)^2)/1) = sqrt(800) = + expected = [ + {"_id": 1, "partition": "A", "x": 10, "y": 2, "result": 28.284271247461902}, + {"_id": 2, "partition": "A", "x": 20, "y": None, "result": 28.284271247461902}, + {"_id": 3, "partition": "A", "x": 30, "y": 2, "result": 28.284271247461902}, + ] + assertSuccess(result, expected, msg="expression returning null for some — null results ignored") + + +def test_stddevsamp_project_removes_expression_field(collection): + """$project removing the expression field before $setWindowFields — treated as missing.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10, "extra": 99}, + {"_id": 2, "partition": "A", "value": 20, "extra": 99}, + {"_id": 3, "partition": "A", "value": 30, "extra": 99}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$project": {"partition": 1, "extra": 1}}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # All docs missing "value" -> all non-numeric -> result is null + expected = [ + {"_id": 1, "partition": "A", "extra": 99, "result": None}, + {"_id": 2, "partition": "A", "extra": 99, "result": None}, + {"_id": 3, "partition": "A", "extra": 99, "result": None}, + ] + assertSuccess(result, expected, msg="$project removing expression field -> all null results") + + +def test_stddevsamp_unset_partitionby_field(collection): + """$unset removing partitionBy field — all docs share one partition (null key).""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "B", "value": 20}, + {"_id": 3, "partition": "C", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$unset": "partition"}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # All docs have missing partition -> single partition -> stdDevSamp([10,20,30]) = 10.0 + expected = [ + {"_id": 1, "value": 10, "result": 10.0}, + {"_id": 2, "value": 20, "result": 10.0}, + {"_id": 3, "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="$unset partition field -> all docs in one partition") + + +def test_stddevsamp_match_filters_to_empty(collection): + """$match filtering all documents before $setWindowFields — zero output.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$match": {"value": {"$gt": 999}}}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + assertSuccess(result, [], msg="$match filters to empty before $setWindowFields -> no output") + + +def test_stddevsamp_date_value_as_expression_ignored(collection): + """$stdDevSamp with Date value in expression field — non-numeric, ignored.""" + docs = [ + {"_id": 1, "partition": "A", "value": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": datetime(2023, 6, 1, tzinfo=timezone.utc)}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Only doc 2 is numeric -> single value -> stdDevSamp = null (N=1, N-1=0) + expected = [ + { + "_id": 1, + "partition": "A", + "value": datetime(2023, 1, 1, tzinfo=timezone.utc), + "result": None, + }, + {"_id": 2, "partition": "A", "value": 20, "result": None}, + { + "_id": 3, + "partition": "A", + "value": datetime(2023, 6, 1, tzinfo=timezone.utc), + "result": None, + }, + ] + assertSuccess( + result, + expected, + msg="Date values in expression field are non-numeric — only 1 numeric gives null", + ) + + +def test_stddevsamp_addfields_then_setwindowfields(collection): + """$addFields creating a field, then $setWindowFields using it.""" + docs = [ + {"_id": 1, "partition": "A", "x": 5, "y": 2}, + {"_id": 2, "partition": "A", "x": 10, "y": 3}, + {"_id": 3, "partition": "A", "x": 15, "y": 1}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$addFields": {"computed": {"$multiply": ["$x", "$y"]}}}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$computed", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # computed: [10, 30, 15] -> mean=55/3 + # deviations: 10-55/3=-25/3, 30-55/3=35/3, 15-55/3=-10/3 + # sum_sq: 625/9 + 1225/9 + 100/9 = 1950/9 + # var_samp = (1950/9) / 2 = 975/9 = 108.333... + # stdDevSamp = sqrt(975/9) = 10.408329997330663 + expected = [ + {"_id": 1, "partition": "A", "x": 5, "y": 2, "computed": 10, "result": 10.408329997330663}, + {"_id": 2, "partition": "A", "x": 10, "y": 3, "computed": 30, "result": 10.408329997330663}, + {"_id": 3, "partition": "A", "x": 15, "y": 1, "computed": 15, "result": 10.408329997330663}, + ] + assertSuccess(result, expected, msg="$addFields + $setWindowFields on computed field") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_non_numeric_handling.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_non_numeric_handling.py new file mode 100644 index 000000000..0577ca064 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_non_numeric_handling.py @@ -0,0 +1,803 @@ +""" +Tests for $stdDevSamp null, missing, and non-numeric value handling. + +Covers: null values, missing fields, strings, booleans, arrays, objects, +mixed numeric and non-numeric in same frame, all non-numeric returns null, +and edge cases (single value returns null, all identical returns 0). +""" + +from datetime import datetime, timezone + +from bson import Binary, ObjectId, Regex + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_stdDevSamp_null_values_ignored(collection): + """$stdDevSamp ignores null values in the expression field.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": None}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # numerics [10, 30] → stdDevSamp = sqrt(((10-20)^2+(30-20)^2)/1) = sqrt(200) = 14.142 + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "value": None, "result": 14.142135623730951}, + {"_id": 3, "partition": "A", "value": 30, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="null values ignored, stdDevSamp of 10,30 = 14.142") + + +def test_stdDevSamp_missing_field_ignored(collection): + """$stdDevSamp ignores documents where the expression field is missing.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A"}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "result": 14.142135623730951}, + {"_id": 3, "partition": "A", "value": 30, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="missing field ignored, stdDevSamp of 10,30 = 14.142") + + +def test_stdDevSamp_string_values_ignored(collection): + """$stdDevSamp ignores string values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": "hello"}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "value": "hello", "result": 14.142135623730951}, + {"_id": 3, "partition": "A", "value": 30, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="string values ignored") + + +def test_stdDevSamp_boolean_values_ignored(collection): + """$stdDevSamp ignores boolean values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": True}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "value": True, "result": 14.142135623730951}, + {"_id": 3, "partition": "A", "value": 30, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="boolean values ignored") + + +def test_stdDevSamp_array_values_ignored(collection): + """$stdDevSamp ignores array values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": [1, 2, 3]}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "value": [1, 2, 3], "result": 14.142135623730951}, + {"_id": 3, "partition": "A", "value": 30, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="array values ignored") + + +def test_stdDevSamp_object_values_ignored(collection): + """$stdDevSamp ignores object/document values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": {"nested": 99}}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "value": {"nested": 99}, "result": 14.142135623730951}, + {"_id": 3, "partition": "A", "value": 30, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="object values ignored") + + +def test_stdDevSamp_all_non_numeric_returns_null(collection): + """$stdDevSamp returns null when all values in frame are non-numeric.""" + docs = [ + {"_id": 1, "partition": "A", "value": "a"}, + {"_id": 2, "partition": "A", "value": None}, + {"_id": 3, "partition": "A"}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": "a", "result": None}, + {"_id": 2, "partition": "A", "value": None, "result": None}, + {"_id": 3, "partition": "A", "result": None}, + ] + assertSuccess(result, expected, msg="all non-numeric in frame returns null") + + +def test_stdDevSamp_mixed_numeric_non_numeric_sliding(collection): + """$stdDevSamp in sliding window with mix of numeric and non-numeric values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10.0}, + {"_id": 2, "partition": "A", "value": "skip"}, + {"_id": 3, "partition": "A", "value": 20.0}, + {"_id": 4, "partition": "A", "value": None}, + {"_id": 5, "partition": "A", "value": 30.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": [-2, 2]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Window [-2, 2] (5-doc centered): + # Row 1: frame [10.0, "skip", 20.0] → numerics [10, 20] → stdDevSamp = 7.071 + # Row 2: frame [10.0, "skip", 20.0, None] → numerics [10, 20] → 7.071 + # Row 3: frame [10.0, "skip", 20.0, None, 30.0] → numerics [10, 20, 30] → 10.0 + # Row 4: frame ["skip", 20.0, None, 30.0] → numerics [20, 30] → 7.071 + # Row 5: frame [20.0, None, 30.0] → numerics [20, 30] → 7.071 + expected = [ + {"_id": 1, "partition": "A", "value": 10.0, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "value": "skip", "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": 20.0, "result": 10.0}, + {"_id": 4, "partition": "A", "value": None, "result": 7.0710678118654755}, + {"_id": 5, "partition": "A", "value": 30.0, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="mixed types in sliding window — non-numeric ignored") + + +def test_stdDevSamp_single_numeric_in_frame_returns_null(collection): + """$stdDevSamp returns null when only one numeric value exists in frame (N-1=0).""" + docs = [ + {"_id": 1, "partition": "A", "value": "text"}, + {"_id": 2, "partition": "A", "value": None}, + {"_id": 3, "partition": "A", "value": 42}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Only one numeric (42) in the whole partition → stdDevSamp = null (N-1=0) + expected = [ + {"_id": 1, "partition": "A", "value": "text", "result": None}, + {"_id": 2, "partition": "A", "value": None, "result": None}, + {"_id": 3, "partition": "A", "value": 42, "result": None}, + ] + assertSuccess(result, expected, msg="single numeric value → stdDevSamp = null (N-1=0)") + + +def test_stdDevSamp_all_identical_values_returns_zero(collection): + """$stdDevSamp returns 0 when all values in the frame are identical.""" + docs = [ + {"_id": 1, "partition": "A", "value": 7.0}, + {"_id": 2, "partition": "A", "value": 7.0}, + {"_id": 3, "partition": "A", "value": 7.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 7.0, "result": 0.0}, + {"_id": 2, "partition": "A", "value": 7.0, "result": 0.0}, + {"_id": 3, "partition": "A", "value": 7.0, "result": 0.0}, + ] + assertSuccess(result, expected, msg="identical values produce stdDevSamp = 0") + + +def test_stdDevSamp_negative_numbers(collection): + """$stdDevSamp handles negative numbers correctly.""" + docs = [ + {"_id": 1, "partition": "A", "value": -10}, + {"_id": 2, "partition": "A", "value": 0}, + {"_id": 3, "partition": "A", "value": 10}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": -10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": 0, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 10, "result": 10.0}, + ] + assertSuccess(result, expected, msg="negative numbers handled correctly") + + +def test_stdDevSamp_decimals(collection): + """$stdDevSamp handles floating-point (double) values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1.5}, + {"_id": 2, "partition": "A", "value": 2.5}, + {"_id": 3, "partition": "A", "value": 3.5}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 1.5, "result": 1.0}, + {"_id": 2, "partition": "A", "value": 2.5, "result": 1.0}, + {"_id": 3, "partition": "A", "value": 3.5, "result": 1.0}, + ] + assertSuccess(result, expected, msg="floating-point values handled correctly") + + +def test_stdDevSamp_expression_input(collection): + """$stdDevSamp with computed expression as input.""" + docs = [ + {"_id": 1, "partition": "A", "x": 5, "y": 2}, + {"_id": 2, "partition": "A", "x": 10, "y": 2}, + {"_id": 3, "partition": "A", "x": 15, "y": 2}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": {"$multiply": ["$x", "$y"]}, + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Values: [10, 20, 30] → stdDevSamp = 10.0 + expected = [ + {"_id": 1, "partition": "A", "x": 5, "y": 2, "result": 10.0}, + {"_id": 2, "partition": "A", "x": 10, "y": 2, "result": 10.0}, + {"_id": 3, "partition": "A", "x": 15, "y": 2, "result": 10.0}, + ] + assertSuccess(result, expected, msg="expression input ($multiply) works") + + +def test_stddevsamp_mixed_types_in_documents(collection): + """$stdDevSamp over documents with diverse value types — only numerics participate.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": "hello"}, + {"_id": 3, "partition": "A", "value": True}, + {"_id": 4, "partition": "A", "value": datetime(2023, 6, 1, tzinfo=timezone.utc)}, + {"_id": 5, "partition": "A", "value": 30}, + {"_id": 6, "partition": "A", "value": None}, + {"_id": 7, "partition": "A", "value": [1, 2, 3]}, + {"_id": 8, "partition": "A", "value": {"nested": 99}}, + {"_id": 9, "partition": "A", "value": 50}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$project": {"_id": 1, "result": 1}}, + ], + "cursor": {}, + }, + ) + # Only numeric: [10, 30, 50] → mean=30, var_samp=(400+0+400)/2=400, stdDevSamp=20.0 + expected = [ + {"_id": 1, "result": 20.0}, + {"_id": 2, "result": 20.0}, + {"_id": 3, "result": 20.0}, + {"_id": 4, "result": 20.0}, + {"_id": 5, "result": 20.0}, + {"_id": 6, "result": 20.0}, + {"_id": 7, "result": 20.0}, + {"_id": 8, "result": 20.0}, + {"_id": 9, "result": 20.0}, + ] + assertSuccess(result, expected, msg="diverse non-numeric types ignored in whole partition") + + +def test_stddevsamp_mixed_types_sliding_window(collection): + """$stdDevSamp sliding window over documents with diverse types — per-frame filtering.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10.0}, + {"_id": 2, "partition": "A", "value": 20.0}, + {"_id": 3, "partition": "A", "value": "skip"}, + {"_id": 4, "partition": "A", "value": True}, + {"_id": 5, "partition": "A", "value": 30.0}, + {"_id": 6, "partition": "A", "value": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + {"_id": 7, "partition": "A", "value": 40.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Whole partition: numerics [10, 20, 30, 40] → mean=25 + # var_samp = (225+25+25+225)/3 = 500/3 = 166.67, stdDevSamp = 12.9099... + expected = [ + {"_id": 1, "partition": "A", "value": 10.0, "result": 12.909944487358056}, + {"_id": 2, "partition": "A", "value": 20.0, "result": 12.909944487358056}, + {"_id": 3, "partition": "A", "value": "skip", "result": 12.909944487358056}, + {"_id": 4, "partition": "A", "value": True, "result": 12.909944487358056}, + {"_id": 5, "partition": "A", "value": 30.0, "result": 12.909944487358056}, + { + "_id": 6, + "partition": "A", + "value": datetime(2023, 1, 1, tzinfo=timezone.utc), + "result": 12.909944487358056, + }, + {"_id": 7, "partition": "A", "value": 40.0, "result": 12.909944487358056}, + ] + assertSuccess(result, expected, msg="whole partition filters non-numeric types") + + +def test_stddevsamp_objectid_and_regex_and_binary_ignored(collection): + """$stdDevSamp ignores ObjectId, Regex, and Binary values.""" + oid = ObjectId("507f1f77bcf86cd799439011") + docs = [ + {"_id": 1, "partition": "A", "value": oid}, + {"_id": 2, "partition": "A", "value": Regex("^test", "i")}, + {"_id": 3, "partition": "A", "value": Binary(b"\x01\x02\x03")}, + {"_id": 4, "partition": "A", "value": 10}, + {"_id": 5, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$project": {"_id": 1, "result": 1}}, + ], + "cursor": {}, + }, + ) + # Only numeric: [10, 30] → stdDevSamp = sqrt(200/1) = 14.142 + expected = [ + {"_id": 1, "result": 14.142135623730951}, + {"_id": 2, "result": 14.142135623730951}, + {"_id": 3, "result": 14.142135623730951}, + {"_id": 4, "result": 14.142135623730951}, + {"_id": 5, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="ObjectId/Regex/Binary values ignored") + + +def test_stddevsamp_all_non_numeric_diverse_types(collection): + """$stdDevSamp returns null when all values are diverse non-numeric types.""" + docs = [ + {"_id": 1, "partition": "A", "value": "text"}, + {"_id": 2, "partition": "A", "value": True}, + {"_id": 3, "partition": "A", "value": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + {"_id": 4, "partition": "A", "value": [1, 2]}, + {"_id": 5, "partition": "A", "value": {"a": 1}}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": "text", "result": None}, + {"_id": 2, "partition": "A", "value": True, "result": None}, + { + "_id": 3, + "partition": "A", + "value": datetime(2023, 1, 1, tzinfo=timezone.utc), + "result": None, + }, + {"_id": 4, "partition": "A", "value": [1, 2], "result": None}, + {"_id": 5, "partition": "A", "value": {"a": 1}, "result": None}, + ] + assertSuccess(result, expected, msg="all diverse non-numeric types return null") + + +def test_stddevsamp_numeric_among_diverse_types_cumulative(collection): + """$stdDevSamp cumulative window with numerics scattered among diverse types.""" + docs = [ + {"_id": 1, "partition": "A", "value": "text"}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": datetime(2023, 6, 1, tzinfo=timezone.utc)}, + {"_id": 4, "partition": "A", "value": 20}, + {"_id": 5, "partition": "A", "value": True}, + {"_id": 6, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Cumulative, only numerics count: + # Row 1: no numeric → null + # Row 2: [10] → null (N=1, N-1=0) + # Row 3: [10] → null (datetime ignored, still one numeric) + # Row 4: [10, 20] → stdDevSamp = 7.071 + # Row 5: [10, 20] → 7.071 (True ignored) + # Row 6: [10, 20, 30] → 10.0 + expected = [ + {"_id": 1, "partition": "A", "value": "text", "result": None}, + {"_id": 2, "partition": "A", "value": 10, "result": None}, + { + "_id": 3, + "partition": "A", + "value": datetime(2023, 6, 1, tzinfo=timezone.utc), + "result": None, + }, + {"_id": 4, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 5, "partition": "A", "value": True, "result": 7.0710678118654755}, + {"_id": 6, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="cumulative window with numerics among diverse types") + + +def test_stdDevSamp_numerics_among_non_numeric_sliding(collection): + """$stdDevSamp sliding frame filters non-numerics and computes on remaining values.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10.0}, + {"_id": 2, "partition": "A", "value": "a"}, + {"_id": 3, "partition": "A", "value": 30.0}, + {"_id": 4, "partition": "A", "value": "b"}, + {"_id": 5, "partition": "A", "value": 50.0}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": [-2, 2]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Window [-2, 2] (5-doc centered): + # Row 1: numerics [10, 30] → stdDevSamp = sqrt(200/1) = 14.142 + # Row 2: numerics [10, 30] → 14.142 + # Row 3: numerics [10, 30, 50] → sqrt(800/2) = 20.0 + # Row 4: numerics [30, 50] → sqrt(200/1) = 14.142 + # Row 5: numerics [30, 50] → 14.142 + expected = [ + {"_id": 1, "partition": "A", "value": 10.0, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "value": "a", "result": 14.142135623730951}, + {"_id": 3, "partition": "A", "value": 30.0, "result": 20.0}, + {"_id": 4, "partition": "A", "value": "b", "result": 14.142135623730951}, + {"_id": 5, "partition": "A", "value": 50.0, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="non-numeric values filtered in sliding frame") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_numeric_precision.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_numeric_precision.py new file mode 100644 index 000000000..d276929ae --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_numeric_precision.py @@ -0,0 +1,654 @@ +""" +Tests for $stdDevSamp numeric type mixing, overflow edge cases, +algorithmic precision validation, and Decimal128 type handling. +""" + +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertResult, assertSuccess, assertSuccessNaN +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Gt, Lte, PerDoc + + +def _run_stddevsamp(collection, docs, window, partition_by="$partition"): + """Helper to run $stdDevSamp pipeline.""" + collection.insert_many(docs) + pipeline = [ + { + "$setWindowFields": { + "partitionBy": partition_by, + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": window, + } + }, + } + } + ] + return execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + + +def test_stddevsamp_all_int32_values(collection): + """$stdDevSamp with all Int32 values produces correct Double result.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="all Int32 values produce correct Double result") + + +def test_stddevsamp_all_int64_values(collection): + """$stdDevSamp with all Int64 values produces correct result.""" + docs = [ + {"_id": 1, "partition": "A", "value": Int64(10)}, + {"_id": 2, "partition": "A", "value": Int64(20)}, + {"_id": 3, "partition": "A", "value": Int64(30)}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Int64(10), "result": 10.0}, + {"_id": 2, "partition": "A", "value": Int64(20), "result": 10.0}, + {"_id": 3, "partition": "A", "value": Int64(30), "result": 10.0}, + ] + assertSuccess(result, expected, msg="all Int64 values compute correctly") + + +def test_stddevsamp_mixed_int32_int64_double(collection): + """$stdDevSamp with mixed Int32 + Int64 + Double in same frame.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": Int64(20)}, + {"_id": 3, "partition": "A", "value": 30.0}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": Int64(20), "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30.0, "result": 10.0}, + ] + assertSuccess(result, expected, msg="mixed Int32 + Int64 + Double type promotion works") + + +def test_stddevsamp_large_int64_near_max(collection): + """$stdDevSamp with Int64 values near MAX_LONG — squaring would overflow 64-bit.""" + docs = [ + {"_id": 1, "partition": "A", "value": Int64(9223372036854775806)}, + {"_id": 2, "partition": "A", "value": Int64(9223372036854775807)}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # Both values round to the same float64 (2^63-1 and 2^63-2 differ by 1 ULP at this scale), + # so the server sees them as identical -> stdDevSamp = 0.0 + expected = [ + {"_id": 1, "partition": "A", "value": Int64(9223372036854775806), "result": 0.0}, + {"_id": 2, "partition": "A", "value": Int64(9223372036854775807), "result": 0.0}, + ] + assertSuccess(result, expected, msg="Int64 near MAX_LONG does not overflow") + + +def test_stddevsamp_large_int64_spread(collection): + """$stdDevSamp with widely spread Int64 values — tests numeric stability.""" + docs = [ + {"_id": 1, "partition": "A", "value": Int64(0)}, + {"_id": 2, "partition": "A", "value": Int64(4611686018427387903)}, + {"_id": 3, "partition": "A", "value": Int64(9223372036854775807)}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # Verify 3 docs returned, each with a positive result (exact value depends on precision) + checks = PerDoc( + {"result": Gt(0)}, + {"result": Gt(0)}, + {"result": Gt(0)}, + ) + assertResult(result, expected=checks, msg="Large Int64 spread produces positive result") + + +def test_stddevsamp_known_exact_result(collection): + """$stdDevSamp of [2,4,4,4,5,5,7,9] = sqrt(32/7) = 2.138089935299395.""" + docs = [ + {"_id": i, "partition": "A", "value": v} for i, v in enumerate([2, 4, 4, 4, 5, 5, 7, 9], 1) + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # mean=5, sum_sq=32, var_samp=32/7, stdDevSamp=sqrt(32/7)=2.138089935299395 + expected = [ + {"_id": i, "partition": "A", "value": v, "result": 2.138089935299395} + for i, v in enumerate([2, 4, 4, 4, 5, 5, 7, 9], 1) + ] + assertSuccess(result, expected, msg="stdDevSamp of [2,4,4,4,5,5,7,9] must be sqrt(32/7)") + + +def test_stddevsamp_identical_floats_exactly_zero(collection): + """$stdDevSamp of identical float values must be exactly 0.0, not epsilon.""" + docs = [ + {"_id": 1, "partition": "A", "value": 3.0}, + {"_id": 2, "partition": "A", "value": 3.0}, + {"_id": 3, "partition": "A", "value": 3.0}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 3.0, "result": 0.0}, + {"_id": 2, "partition": "A", "value": 3.0, "result": 0.0}, + {"_id": 3, "partition": "A", "value": 3.0, "result": 0.0}, + ] + assertSuccess(result, expected, msg="identical floats produce exactly 0.0") + + +def test_stddevsamp_single_element_sliding_window(collection): + """$stdDevSamp returns null when sliding window frame has exactly one value.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10.0}, + {"_id": 2, "partition": "A", "value": 20.0}, + {"_id": 3, "partition": "A", "value": 30.0}, + {"_id": 4, "partition": "A", "value": 40.0}, + {"_id": 5, "partition": "A", "value": 50.0}, + ] + result = _run_stddevsamp(collection, docs, {"documents": [0, 0]}) + # Window [0, 0] — each frame has exactly one value -> stdDevSamp = null (N-1 = 0) + expected = [ + {"_id": 1, "partition": "A", "value": 10.0, "result": None}, + {"_id": 2, "partition": "A", "value": 20.0, "result": None}, + {"_id": 3, "partition": "A", "value": 30.0, "result": None}, + {"_id": 4, "partition": "A", "value": 40.0, "result": None}, + {"_id": 5, "partition": "A", "value": 50.0, "result": None}, + ] + assertSuccess(result, expected, msg="single element in sliding frame returns null") + + +def test_stddevsamp_catastrophic_cancellation(collection): + """$stdDevSamp of [1000000001, 1000000002, 1000000003] — naive sum-of-squares fails.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1000000001}, + {"_id": 2, "partition": "A", "value": 1000000002}, + {"_id": 3, "partition": "A", "value": 1000000003}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # stdDevSamp of [N, N+1, N+2]: mean=N+1, sum_sq=2, var_samp=2/2=1, stdDevSamp=1.0 + expected = [ + {"_id": 1, "partition": "A", "value": 1000000001, "result": 1.0}, + {"_id": 2, "partition": "A", "value": 1000000002, "result": 1.0}, + {"_id": 3, "partition": "A", "value": 1000000003, "result": 1.0}, + ] + assertSuccess( + result, + expected, + msg="catastrophic cancellation handled — correct stddev for large offset values", + ) + + +def test_stddevsamp_very_small_differences(collection): + """$stdDevSamp with values that differ by very small amounts.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1.0000001}, + {"_id": 2, "partition": "A", "value": 1.0000002}, + {"_id": 3, "partition": "A", "value": 1.0000003}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # The stdDevSamp should be approximately 1e-7 — verify positive and tiny + checks = PerDoc( + {"result": [Gt(0), Lte(0.001)]}, + {"result": [Gt(0), Lte(0.001)]}, + {"result": [Gt(0), Lte(0.001)]}, + ) + assertResult( + result, expected=checks, msg="Small differences produce very small positive stddev" + ) + + +def test_stddevsamp_alternating_large_values(collection): + """$stdDevSamp with alternating sign large values — stress variance accumulator.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1e15}, + {"_id": 2, "partition": "A", "value": -1e15}, + {"_id": 3, "partition": "A", "value": 1e15}, + {"_id": 4, "partition": "A", "value": -1e15}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # mean=0, sum_sq=4e30, var_samp=4e30/3, stdDevSamp=sqrt(4e30/3)=2e15/sqrt(3) + expected = [ + {"_id": 1, "partition": "A", "value": 1e15, "result": 1.1547005383792515e15}, + {"_id": 2, "partition": "A", "value": -1e15, "result": 1.1547005383792515e15}, + {"_id": 3, "partition": "A", "value": 1e15, "result": 1.1547005383792515e15}, + {"_id": 4, "partition": "A", "value": -1e15, "result": 1.1547005383792515e15}, + ] + assertSuccess(result, expected, msg="alternating large values produce correct stddev") + + +def test_stddevsamp_whole_partition_vs_explicit_unbounded(collection): + """$stdDevSamp no-window vs explicit unbounded-unbounded must be identical.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + # Run with explicit unbounded-unbounded + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "explicit": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + }, + "default": { + "$stdDevSamp": "$value", + }, + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "explicit": 10.0, "default": 10.0}, + {"_id": 2, "partition": "A", "value": 20, "explicit": 10.0, "default": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "explicit": 10.0, "default": 10.0}, + ] + assertSuccess(result, expected, msg="explicit unbounded-unbounded equals default (no window)") + + +def test_stddevsamp_int32_sum_of_squares_overflow(collection): + """$stdDevSamp with Int32 values where sum-of-squares would overflow Int32.""" + # Max Int32 = 2147483647. Values around 50000: 50000^2 = 2.5e9 > 2^31 + docs = [ + {"_id": 1, "partition": "A", "value": 50000}, + {"_id": 2, "partition": "A", "value": 50001}, + {"_id": 3, "partition": "A", "value": 50002}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # stdDevSamp of [N, N+1, N+2]: mean=N+1, sum_sq=2, var_samp=2/2=1, stdDevSamp=1.0 + expected = [ + {"_id": 1, "partition": "A", "value": 50000, "result": 1.0}, + {"_id": 2, "partition": "A", "value": 50001, "result": 1.0}, + {"_id": 3, "partition": "A", "value": 50002, "result": 1.0}, + ] + assertSuccess(result, expected, msg="Int32 values that would overflow Int32 sum-of-squares") + + +def test_stddevsamp_very_large_value(collection): + """$stdDevSamp with very large numeric value (1e308) — all identical returns 0.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1e308}, + {"_id": 2, "partition": "A", "value": 1e308}, + {"_id": 3, "partition": "A", "value": 1e308}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 1e308, "result": 0.0}, + {"_id": 2, "partition": "A", "value": 1e308, "result": 0.0}, + {"_id": 3, "partition": "A", "value": 1e308, "result": 0.0}, + ] + assertSuccess(result, expected, msg="very large identical values return 0") + + +def test_stddevsamp_sequential_values_1_to_10(collection): + """$stdDevSamp over sequential values 1..10 with known exact result.""" + docs = [ + {"_id": 1, "partition": "A", "value": 1}, + {"_id": 2, "partition": "A", "value": 2}, + {"_id": 3, "partition": "A", "value": 3}, + {"_id": 4, "partition": "A", "value": 4}, + {"_id": 5, "partition": "A", "value": 5}, + {"_id": 6, "partition": "A", "value": 6}, + {"_id": 7, "partition": "A", "value": 7}, + {"_id": 8, "partition": "A", "value": 8}, + {"_id": 9, "partition": "A", "value": 9}, + {"_id": 10, "partition": "A", "value": 10}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$match": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + # stdDevSamp of 1..10: mean=5.5, sum_sq=82.5, var_samp=82.5/9, stdDevSamp=sqrt(82.5/9) + expected = [ + {"_id": 1, "partition": "A", "value": 1, "result": 3.0276503540974917}, + ] + assertSuccess(result, expected, msg="sequential 1..10 known stdDevSamp result") + + +def _run_stddevsamp_decimal(collection, docs, window, extra_stages=None): + """Helper to run $stdDevSamp pipeline with optional extra stages.""" + collection.insert_many(docs) + pipeline = [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": window, + } + }, + } + } + ] + if extra_stages: + pipeline.extend(extra_stages) + return execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + + +def test_stddevsamp_pure_decimal128_values(collection): + """$stdDevSamp with pure Decimal128 values computes correctly.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Decimal128("20")}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + result = _run_stddevsamp_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": 10.0}, + {"_id": 2, "partition": "A", "value": Decimal128("20"), "result": 10.0}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": 10.0}, + ] + assertSuccess(result, expected, msg="pure Decimal128 values compute stdDevSamp correctly") + + +def test_stddevsamp_decimal128_with_double(collection): + """$stdDevSamp with mixed Decimal128 and Double values.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": 20.0}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + result = _run_stddevsamp_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": 10.0}, + {"_id": 2, "partition": "A", "value": 20.0, "result": 10.0}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": 10.0}, + ] + assertSuccess(result, expected, msg="mixed Decimal128 and Double compute correctly") + + +def test_stddevsamp_decimal128_with_int32(collection): + """$stdDevSamp with mixed Decimal128 and Int32 values.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + result = _run_stddevsamp_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": 10.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": 10.0}, + ] + assertSuccess(result, expected, msg="mixed Decimal128 and Int32 compute correctly") + + +def test_stddevsamp_decimal128_with_int64(collection): + """$stdDevSamp with mixed Decimal128 and Int64 values.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Int64(20)}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + result = _run_stddevsamp_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": 10.0}, + {"_id": 2, "partition": "A", "value": Int64(20), "result": 10.0}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": 10.0}, + ] + assertSuccess(result, expected, msg="mixed Decimal128 and Int64 compute correctly") + + +def test_stddevsamp_decimal128_all_types_mixed(collection): + """$stdDevSamp with Decimal128 + Double + Int32 + Int64 all in same frame.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": 20.0}, + {"_id": 3, "partition": "A", "value": 30}, + {"_id": 4, "partition": "A", "value": Int64(40)}, + ] + result = _run_stddevsamp_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + # stdDevSamp([10,20,30,40]): mean=25, sum_sq=500, var_samp=500/3, stdDevSamp=sqrt(500/3) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": 12.909944487358056}, + {"_id": 2, "partition": "A", "value": 20.0, "result": 12.909944487358056}, + {"_id": 3, "partition": "A", "value": 30, "result": 12.909944487358056}, + {"_id": 4, "partition": "A", "value": Int64(40), "result": 12.909944487358056}, + ] + assertSuccess(result, expected, msg="all four numeric types mixed in same frame") + + +def test_stddevsamp_decimal128_high_precision(collection): + """$stdDevSamp with high-precision Decimal128 values preserves precision.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("1.000000000000000000000000000000001")}, + {"_id": 2, "partition": "A", "value": Decimal128("2.000000000000000000000000000000001")}, + {"_id": 3, "partition": "A", "value": Decimal128("3.000000000000000000000000000000001")}, + ] + extra_stages = [ + {"$addFields": {"result": {"$round": ["$result", 3]}}}, + {"$project": {"_id": 1, "result": 1}}, + ] + result = _run_stddevsamp_decimal( + collection, docs, {"documents": ["unbounded", "unbounded"]}, extra_stages=extra_stages + ) + expected = [ + {"_id": 1, "result": 1.0}, + {"_id": 2, "result": 1.0}, + {"_id": 3, "result": 1.0}, + ] + assertSuccess(result, expected, msg="high-precision Decimal128 values produce ~1.0") + + +def test_stddevsamp_decimal128_near_zero(collection): + """$stdDevSamp with very small Decimal128 values near zero.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("0.0000000000000000000000000000000001")}, + {"_id": 2, "partition": "A", "value": Decimal128("0.0000000000000000000000000000000002")}, + {"_id": 3, "partition": "A", "value": Decimal128("0.0000000000000000000000000000000003")}, + ] + extra_stages = [ + { + "$addFields": { + "valid": { + "$and": [ + {"$ne": ["$result", None]}, + {"$gte": ["$result", 0]}, + ] + } + } + }, + {"$project": {"_id": 1, "valid": 1}}, + ] + result = _run_stddevsamp_decimal( + collection, docs, {"documents": ["unbounded", "unbounded"]}, extra_stages=extra_stages + ) + expected = [ + {"_id": 1, "valid": True}, + {"_id": 2, "valid": True}, + {"_id": 3, "valid": True}, + ] + assertSuccess(result, expected, msg="near-zero Decimal128 produces non-negative result") + + +def test_stddevsamp_decimal128_nan_special(collection): + """$stdDevSamp with Decimal128 NaN — NaN is numeric and poisons the calculation.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Decimal128("NaN")}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + result = _run_stddevsamp_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": float("nan")}, + {"_id": 2, "partition": "A", "value": Decimal128("NaN"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": float("nan")}, + ] + assertSuccessNaN( + result, expected, msg="Decimal128 NaN is numeric and poisons stdDevSamp to NaN" + ) + + +def test_stddevsamp_decimal128_infinity_special(collection): + """$stdDevSamp with Decimal128 Infinity special value.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Decimal128("Infinity")}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + extra_stages = [ + { + "$addFields": { + "has_result": {"$ne": ["$result", None]}, + } + }, + {"$project": {"_id": 1, "has_result": 1}}, + ] + result = _run_stddevsamp_decimal( + collection, docs, {"documents": ["unbounded", "unbounded"]}, extra_stages=extra_stages + ) + expected = [ + {"_id": 1, "has_result": True}, + {"_id": 2, "has_result": True}, + {"_id": 3, "has_result": True}, + ] + assertSuccess(result, expected, msg="Decimal128 Infinity produces a non-null result") + + +def test_stddevsamp_decimal128_neg_infinity_special(collection): + """$stdDevSamp with Decimal128 -Infinity special value.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Decimal128("-Infinity")}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + ] + extra_stages = [ + { + "$addFields": { + "has_result": {"$ne": ["$result", None]}, + } + }, + {"$project": {"_id": 1, "has_result": 1}}, + ] + result = _run_stddevsamp_decimal( + collection, docs, {"documents": ["unbounded", "unbounded"]}, extra_stages=extra_stages + ) + expected = [ + {"_id": 1, "has_result": True}, + {"_id": 2, "has_result": True}, + {"_id": 3, "has_result": True}, + ] + assertSuccess(result, expected, msg="Decimal128 -Infinity produces a non-null result") + + +def test_stddevsamp_decimal128_sliding_window(collection): + """$stdDevSamp with Decimal128 values in a sliding window.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("10")}, + {"_id": 2, "partition": "A", "value": Decimal128("20")}, + {"_id": 3, "partition": "A", "value": Decimal128("30")}, + {"_id": 4, "partition": "A", "value": Decimal128("40")}, + {"_id": 5, "partition": "A", "value": Decimal128("50")}, + ] + collection.insert_many(docs) + # Window [-1, 0]: row 1 has single element -> null, rows 2-5 have pairs -> stdDevSamp + # stdDevSamp of adjacent pairs with diff=10: sqrt((25+25)/1) = sqrt(50) = 7.0710678118654755 + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": [-1, 0]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("10"), "result": None}, + {"_id": 2, "partition": "A", "value": Decimal128("20"), "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": Decimal128("30"), "result": 7.0710678118654755}, + {"_id": 4, "partition": "A", "value": Decimal128("40"), "result": 7.0710678118654755}, + {"_id": 5, "partition": "A", "value": Decimal128("50"), "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="Decimal128 values in sliding window with stdDevSamp") + + +def test_stddevsamp_decimal128_identical_values(collection): + """$stdDevSamp with identical Decimal128 values returns 0.""" + docs = [ + {"_id": 1, "partition": "A", "value": Decimal128("42.5")}, + {"_id": 2, "partition": "A", "value": Decimal128("42.5")}, + {"_id": 3, "partition": "A", "value": Decimal128("42.5")}, + ] + result = _run_stddevsamp_decimal(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": Decimal128("42.5"), "result": 0.0}, + {"_id": 2, "partition": "A", "value": Decimal128("42.5"), "result": 0.0}, + {"_id": 3, "partition": "A", "value": Decimal128("42.5"), "result": 0.0}, + ] + assertSuccess(result, expected, msg="identical Decimal128 values return 0") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_partitioning.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_partitioning.py new file mode 100644 index 000000000..be78a308e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_partitioning.py @@ -0,0 +1,748 @@ +""" +Tests for $stdDevSamp with partitioning and sortBy variations. + +Covers: partition isolation, expression-based partitioning, null/missing +partition keys, descending sort, compound sort, multi-field sort, +no partitionBy, Date sortBy in documents mode, string sortBy. + +Key difference from $stdDevPop: single element in frame returns null (not 0) +because N-1 = 0 makes the sample variance undefined. +""" + +from datetime import datetime, timezone + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_stdDevSamp_partition_isolation(collection): + """$stdDevSamp frame never crosses partition boundary; single-element rows return null.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + {"_id": 4, "partition": "B", "value": 100}, + {"_id": 5, "partition": "B", "value": 200}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": None}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + {"_id": 4, "partition": "B", "value": 100, "result": None}, + {"_id": 5, "partition": "B", "value": 200, "result": 70.71067811865476}, + ] + assertSuccess(result, expected, msg="partition isolation — frames don't cross boundaries") + + +def test_stdDevSamp_sliding_respects_partition_boundary(collection): + """Sliding window at partition edge does not include docs from other partition.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "B", "value": 30}, + {"_id": 4, "partition": "B", "value": 40}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": [-1, 1]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "B", "value": 30, "result": 7.0710678118654755}, + {"_id": 4, "partition": "B", "value": 40, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="sliding window respects partition boundary") + + +def test_stdDevSamp_no_partitionBy(collection): + """$stdDevSamp with no partitionBy treats entire collection as one partition.""" + docs = [ + {"_id": 1, "value": 10}, + {"_id": 2, "value": 20}, + {"_id": 3, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "value": 10, "result": 10.0}, + {"_id": 2, "value": 20, "result": 10.0}, + {"_id": 3, "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="no partitionBy = single partition over all docs") + + +def test_stdDevSamp_partition_null_missing_grouped(collection): + """Null and missing partition keys share one partition.""" + docs = [ + {"_id": 1, "partition": None, "value": 10}, + {"_id": 2, "value": 20}, + {"_id": 3, "partition": None, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": None, "value": 10, "result": 10.0}, + {"_id": 2, "value": 20, "result": 10.0}, + {"_id": 3, "partition": None, "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="null and missing partition keys share one partition") + + +def test_stdDevSamp_descending_sort_cumulative(collection): + """$stdDevSamp with descending sort reverses accumulation order.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": -1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 3, "partition": "A", "value": 30, "result": None}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + ] + assertSuccess(result, expected, msg="descending sort reverses cumulative order") + + +def test_stdDevSamp_compound_sort(collection): + """$stdDevSamp with compound sortBy for deterministic row ordering.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 1, "value": 40}, + {"_id": 3, "partition": "A", "score": 2, "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1, "_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": None}, + {"_id": 2, "partition": "A", "score": 1, "value": 40, "result": 21.213203435596427}, + {"_id": 3, "partition": "A", "score": 2, "value": 20, "result": 15.275252316519467}, + ] + assertSuccess(result, expected, msg="compound sort for deterministic ordering") + + +def test_stdDevSamp_single_doc_partitions(collection): + """$stdDevSamp with single-doc partitions returns null (not 0 like stdDevPop).""" + docs = [ + {"_id": 1, "partition": "A", "value": 100}, + {"_id": 2, "partition": "B", "value": 200}, + {"_id": 3, "partition": "C", "value": 300}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 100, "result": None}, + {"_id": 2, "partition": "B", "value": 200, "result": None}, + {"_id": 3, "partition": "C", "value": 300, "result": None}, + ] + assertSuccess(result, expected, msg="single-doc partitions return null (N-1=0)") + + +def test_stdDevSamp_uneven_partitions(collection): + """$stdDevSamp with partitions of different sizes; single-doc partition returns null.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + {"_id": 4, "partition": "B", "value": 5}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + {"_id": 4, "partition": "B", "value": 5, "result": None}, + ] + assertSuccess(result, expected, msg="uneven partition sizes; single-doc partition returns null") + + +def test_stdDevSamp_partition_by_expression(collection): + """$stdDevSamp with expression-based partitionBy ($mod groups evens and odds).""" + docs = [ + {"_id": 1, "value": 10}, + {"_id": 2, "value": 20}, + {"_id": 3, "value": 30}, + {"_id": 4, "value": 40}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": {"$mod": ["$_id", 2]}, + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "value": 10, "result": 14.142135623730951}, + {"_id": 2, "value": 20, "result": 14.142135623730951}, + {"_id": 3, "value": 30, "result": 14.142135623730951}, + {"_id": 4, "value": 40, "result": 14.142135623730951}, + ] + assertSuccess(result, expected, msg="expression-based partitionBy ($mod)") + + +def test_stdDevSamp_date_sortby_documents_mode(collection): + """$stdDevSamp with Date-valued sortBy in documents mode (not range).""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 1, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 1, tzinfo=timezone.utc), + "value": 30, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + "result": None, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 1, tzinfo=timezone.utc), + "value": 20, + "result": 7.0710678118654755, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 1, tzinfo=timezone.utc), + "value": 30, + "result": 10.0, + }, + ] + assertSuccess(result, expected, msg="Date sortBy works in documents mode") + + +def test_stdDevSamp_date_sortby_sliding_documents_mode(collection): + """$stdDevSamp with Date-valued sortBy and sliding window in documents mode.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 1, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 1, tzinfo=timezone.utc), + "value": 30, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 4, 1, tzinfo=timezone.utc), + "value": 40, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": [-1, 1]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + "result": 7.0710678118654755, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 1, tzinfo=timezone.utc), + "value": 20, + "result": 10.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 1, tzinfo=timezone.utc), + "value": 30, + "result": 10.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 4, 1, tzinfo=timezone.utc), + "value": 40, + "result": 7.0710678118654755, + }, + ] + assertSuccess(result, expected, msg="Date sortBy sliding window in documents mode") + + +def test_stdDevSamp_string_sortby(collection): + """$stdDevSamp with string-valued sortBy field in documents mode.""" + docs = [ + {"_id": 1, "partition": "A", "name": "alice", "value": 10}, + {"_id": 2, "partition": "A", "name": "bob", "value": 20}, + {"_id": 3, "partition": "A", "name": "carol", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"name": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "name": "alice", "value": 10, "result": None}, + {"_id": 2, "partition": "A", "name": "bob", "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "name": "carol", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="string sortBy works in documents mode") + + +def test_stdDevSamp_missing_sortby_field_sorts_first(collection): + """Documents with missing sortBy field sort before others.""" + docs = [ + {"_id": 1, "partition": "A", "value": 100}, + {"_id": 2, "partition": "A", "score": 5, "value": 10}, + {"_id": 3, "partition": "A", "score": 10, "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 100, "result": None}, + {"_id": 2, "partition": "A", "score": 5, "value": 10, "result": 63.63961030678928}, + {"_id": 3, "partition": "A", "score": 10, "value": 20, "result": 49.32882862316247}, + ] + assertSuccess(result, expected, msg="missing sortBy field sorts first (before all values)") + + +def test_stdDevSamp_many_partitions_single_value_each(collection): + """$stdDevSamp with many single-document partitions all return null.""" + docs = [ + {"_id": 1, "partition": "P1", "value": 10}, + {"_id": 2, "partition": "P2", "value": 20}, + {"_id": 3, "partition": "P3", "value": 30}, + {"_id": 4, "partition": "P4", "value": 40}, + {"_id": 5, "partition": "P5", "value": 50}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "P1", "value": 10, "result": None}, + {"_id": 2, "partition": "P2", "value": 20, "result": None}, + {"_id": 3, "partition": "P3", "value": 30, "result": None}, + {"_id": 4, "partition": "P4", "value": 40, "result": None}, + {"_id": 5, "partition": "P5", "value": 50, "result": None}, + ] + assertSuccess(result, expected, msg="many single-doc partitions all return null") + + +def test_stdDevSamp_empty_collection(collection): + """$stdDevSamp on empty collection returns no documents.""" + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertSuccess(result, [], msg="empty collection returns no results") + + +def test_stdDevSamp_single_document(collection): + """$stdDevSamp on single document returns null (N-1=0, undefined).""" + docs = [{"_id": 1, "partition": "A", "value": 42}] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [{"_id": 1, "partition": "A", "value": 42, "result": None}] + assertSuccess(result, expected, msg="single document returns null (N-1=0)") + + +def test_stdDevSamp_large_partition_cumulative(collection): + """$stdDevSamp whole-partition over 100 documents for correctness at scale.""" + n = 100 + docs = [{"_id": i, "partition": "A", "value": i} for i in range(1, n + 1)] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$match": {"_id": 100}}, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 100, "partition": "A", "value": 100, "result": 29.011491975882016}, + ] + assertSuccess(result, expected, msg="large partition (100 docs) computes correctly") + + +def test_stdDevSamp_large_partition_sliding(collection): + """$stdDevSamp sliding window over 100+ document partition.""" + n = 100 + docs = [{"_id": i, "partition": "A", "value": i * 10} for i in range(1, n + 1)] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": [-2, 2]}, + } + }, + } + }, + {"$match": {"_id": 50}}, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 50, "partition": "A", "value": 500, "result": 15.811388300841896}, + ] + assertSuccess(result, expected, msg="large partition sliding window at midpoint") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_pipeline_stages.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_pipeline_stages.py new file mode 100644 index 000000000..53afa8aa5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_pipeline_stages.py @@ -0,0 +1,518 @@ +""" +Tests for $stdDevSamp in pipeline context: $unionWith sub-pipeline, +$setWindowFields after $addFields/transform, and additional chaining patterns. +""" + +from documentdb_tests.framework.assertions import assertResult, assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Gt, PerDoc + + +def test_stddevsamp_inside_unionwith_pipeline(collection): + """$stdDevSamp inside a $unionWith sub-pipeline.""" + collection.insert_many( + [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + ] + ) + other_coll_name = collection.name + "_other" + other_coll = collection.database[other_coll_name] + other_coll.insert_many( + [ + {"_id": 3, "partition": "B", "value": 30}, + {"_id": 4, "partition": "B", "value": 40}, + ] + ) + try: + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + { + "$unionWith": { + "coll": other_coll_name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "B", "value": 30, "result": 7.0710678118654755}, + {"_id": 4, "partition": "B", "value": 40, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="$stdDevSamp inside $unionWith sub-pipeline") + finally: + other_coll.drop() + + +def test_stddevsamp_after_group_stage(collection): + """$stdDevSamp on $group output — window over grouped results.""" + docs = [ + {"_id": 1, "category": "A", "value": 10}, + {"_id": 2, "category": "A", "value": 20}, + {"_id": 3, "category": "B", "value": 30}, + {"_id": 4, "category": "B", "value": 40}, + {"_id": 5, "category": "C", "value": 50}, + {"_id": 6, "category": "C", "value": 60}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$group": { + "_id": "$category", + "avgValue": {"$avg": "$value"}, + } + }, + {"$sort": {"_id": 1}}, + { + "$setWindowFields": { + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$avgValue", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Group averages: A=15, B=35, C=55 → stdDevSamp of [15, 35, 55] = 20.0 + expected = [ + {"_id": "A", "avgValue": 15.0, "result": 20.0}, + {"_id": "B", "avgValue": 35.0, "result": 20.0}, + {"_id": "C", "avgValue": 55.0, "result": 20.0}, + ] + assertSuccess(result, expected, msg="$setWindowFields after $group") + + +def test_stddevsamp_project_between_two_window_stages(collection): + """$project between two $setWindowFields stages to reshape data.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "runningAvg": { + "$avg": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + {"$project": {"partition": 1, "runningAvg": 1}}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "stdDevOfAvg": { + "$stdDevSamp": "$runningAvg", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Running averages: [10, 15, 20] + # stdDevSamp of [10, 15, 20] = sqrt((25+0+25)/2) = sqrt(25) = 5.0 + expected = [ + {"_id": 1, "partition": "A", "runningAvg": 10.0, "stdDevOfAvg": 5.0}, + {"_id": 2, "partition": "A", "runningAvg": 15.0, "stdDevOfAvg": 5.0}, + {"_id": 3, "partition": "A", "runningAvg": 20.0, "stdDevOfAvg": 5.0}, + ] + assertSuccess(result, expected, msg="$project between two $setWindowFields stages") + + +def test_stddevsamp_three_chained_window_stages(collection): + """Three chained $setWindowFields stages, each using output of previous.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "cumSum": { + "$sum": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "cumAvg": { + "$avg": "$cumSum", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "stdDevResult": { + "$stdDevSamp": "$cumAvg", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # cumSum: [10, 30, 60] + # cumAvg of cumSum: [10, 20, 33.33] + # stdDevSamp of [10, 20, 33.33] > 0 + assertResult( + result, + expected=PerDoc( + {"stdDevResult": Gt(0.0)}, + {"stdDevResult": Gt(0.0)}, + {"stdDevResult": Gt(0.0)}, + ), + msg="Three chained $setWindowFields stages produce positive stdDevResult", + ) + + +def test_stddevsamp_after_unwind(collection): + """$stdDevSamp after $unwind — window operates on unwound documents.""" + docs = [ + {"_id": 1, "partition": "A", "values": [10, 20, 30]}, + {"_id": 2, "partition": "A", "values": [40, 50]}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$unwind": "$values"}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"values": 1}, + "output": { + "result": { + "$stdDevSamp": "$values", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$sort": {"values": 1}}, + ], + "cursor": {}, + }, + ) + # After unwind: 5 docs with values 10, 20, 30, 40, 50 + # stdDevSamp of [10, 20, 30, 40, 50] = sqrt(250) = 15.811388300841896 + expected = [ + {"_id": 1, "partition": "A", "values": 10, "result": 15.811388300841896}, + {"_id": 1, "partition": "A", "values": 20, "result": 15.811388300841896}, + {"_id": 1, "partition": "A", "values": 30, "result": 15.811388300841896}, + {"_id": 2, "partition": "A", "values": 40, "result": 15.811388300841896}, + {"_id": 2, "partition": "A", "values": 50, "result": 15.811388300841896}, + ] + assertSuccess(result, expected, msg="$stdDevSamp after $unwind") + + +def test_stddevsamp_with_limit_before(collection): + """$stdDevSamp with $limit before $setWindowFields — operates on limited set.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + {"_id": 4, "partition": "A", "value": 40}, + {"_id": 5, "partition": "A", "value": 50}, + {"_id": 6, "partition": "A", "value": 60}, + {"_id": 7, "partition": "A", "value": 70}, + {"_id": 8, "partition": "A", "value": 80}, + {"_id": 9, "partition": "A", "value": 90}, + {"_id": 10, "partition": "A", "value": 100}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$sort": {"_id": 1}}, + {"$limit": 3}, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # After limit: 3 docs with values 10, 20, 30 + # stdDevSamp of [10, 20, 30] = 10.0 + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="$stdDevSamp after $limit operates on limited set") + + +def test_stdDevSamp_inside_lookup_pipeline(collection): + """$stdDevSamp inside a $lookup sub-pipeline.""" + collection.insert_many( + [ + {"_id": 1, "category": "A"}, + {"_id": 2, "category": "B"}, + ] + ) + details_coll_name = collection.name + "_details" + details_coll = collection.database[details_coll_name] + details_coll.insert_many( + [ + {"_id": 10, "category": "A", "value": 10}, + {"_id": 11, "category": "A", "value": 20}, + {"_id": 12, "category": "A", "value": 30}, + {"_id": 13, "category": "B", "value": 5}, + {"_id": 14, "category": "B", "value": 15}, + ] + ) + try: + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$lookup": { + "from": details_coll_name, + "let": {"cat": "$category"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$category", "$$cat"]}}}, + { + "$setWindowFields": { + "sortBy": {"_id": 1}, + "output": { + "stdDev": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "as": "details", + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + # A: stdDevSamp([10, 20, 30]) = 10.0 + # B: stdDevSamp([5, 15]) = 7.0710678118654755 + expected = [ + { + "_id": 1, + "category": "A", + "details": [ + {"_id": 10, "category": "A", "value": 10, "stdDev": 10.0}, + {"_id": 11, "category": "A", "value": 20, "stdDev": 10.0}, + {"_id": 12, "category": "A", "value": 30, "stdDev": 10.0}, + ], + }, + { + "_id": 2, + "category": "B", + "details": [ + {"_id": 13, "category": "B", "value": 5, "stdDev": 7.0710678118654755}, + {"_id": 14, "category": "B", "value": 15, "stdDev": 7.0710678118654755}, + ], + }, + ] + assertSuccess(result, expected, msg="$stdDevSamp inside $lookup sub-pipeline") + finally: + details_coll.drop() + + +def test_stdDevSamp_chained_setWindowFields(collection): + """Chained $setWindowFields stages — second stage uses output of first.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "runningSum": { + "$sum": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "stdDevOfSum": { + "$stdDevSamp": "$runningSum", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # runningSum: [10, 30, 60] + # stdDevSamp([10, 30, 60]): mean=100/3, var_samp=11400/18=633.33..., + expected = [ + { + "_id": 1, + "partition": "A", + "value": 10, + "runningSum": 10, + "stdDevOfSum": 25.16611478423583, + }, + { + "_id": 2, + "partition": "A", + "value": 20, + "runningSum": 30, + "stdDevOfSum": 25.16611478423583, + }, + { + "_id": 3, + "partition": "A", + "value": 30, + "runningSum": 60, + "stdDevOfSum": 25.16611478423583, + }, + ] + assertSuccess(result, expected, msg="chained $setWindowFields stages") + + +def test_stdDevSamp_then_match(collection): + """$setWindowFields followed by $match on computed field.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": 1}, + "output": { + "stdDev": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + {"$match": {"stdDev": {"$gt": 0}}}, + ], + "cursor": {}, + }, + ) + # Cumulative stdDevSamp: [10]→null, [10,20]→7.071, [10,20,30]→10.0 + # Match > 0 gives rows 2 and 3 + expected = [ + {"_id": 2, "partition": "A", "value": 20, "stdDev": 7.0710678118654755}, + {"_id": 3, "partition": "A", "value": 30, "stdDev": 10.0}, + ] + assertSuccess(result, expected, msg="$match filters on window-computed field") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_range_mode_frames.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_range_mode_frames.py new file mode 100644 index 000000000..dcf5aa447 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_range_mode_frames.py @@ -0,0 +1,788 @@ +""" +Tests for $stdDevSamp with range-mode windows (numeric and time-range). + +Covers: numeric range with gaps, unbounded/current combinations, +fractional range offsets, time-range with various units, partitioning +in range mode, and value-ties semantics. + +Key difference from $stdDevPop: $stdDevSamp divides by N-1, so a single +element in the frame produces null (not 0). +""" + +from datetime import datetime, timezone + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_stdDevSamp_range_numeric_sliding(collection): + """$stdDevSamp with numeric range window [-10, 10].""" + docs = [ + {"_id": 1, "partition": "A", "score": 0, "value": 10}, + {"_id": 2, "partition": "A", "score": 5, "value": 20}, + {"_id": 3, "partition": "A", "score": 15, "value": 30}, + {"_id": 4, "partition": "A", "score": 25, "value": 40}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": [-10, 10]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Row 1 (score=0): includes 0,5 → [10,20] → stdDevSamp = sqrt(50) = 7.071... + # Row 2 (score=5): includes 0,5,15 → [10,20,30] → stdDevSamp = 10.0 + # Row 3 (score=15): includes 5,15,25 → [20,30,40] → stdDevSamp = 10.0 + # Row 4 (score=25): includes 15,25 → [30,40] → stdDevSamp = sqrt(50) = 7.071... + expected = [ + {"_id": 1, "partition": "A", "score": 0, "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "score": 5, "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "score": 15, "value": 30, "result": 10.0}, + {"_id": 4, "partition": "A", "score": 25, "value": 40, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="numeric range [-10,10] with stdDevSamp") + + +def test_stdDevSamp_range_zero_zero_includes_ties(collection): + """Range [0, 0] includes all docs with the same sortBy value (value-ties).""" + docs = [ + {"_id": 1, "partition": "A", "score": 10, "value": 5}, + {"_id": 2, "partition": "A", "score": 10, "value": 15}, + {"_id": 3, "partition": "A", "score": 10, "value": 25}, + {"_id": 4, "partition": "A", "score": 20, "value": 100}, + {"_id": 5, "partition": "A", "score": 20, "value": 110}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": [0, 0]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # score=10 ties: stdDevSamp([5, 15, 25]) = sqrt(200/2) = 10.0 + # score=20 ties: stdDevSamp([100, 110]) = sqrt(50/1) = 7.071... + expected = [ + {"_id": 1, "partition": "A", "score": 10, "value": 5, "result": 10.0}, + {"_id": 2, "partition": "A", "score": 10, "value": 15, "result": 10.0}, + {"_id": 3, "partition": "A", "score": 10, "value": 25, "result": 10.0}, + {"_id": 4, "partition": "A", "score": 20, "value": 100, "result": 7.0710678118654755}, + {"_id": 5, "partition": "A", "score": 20, "value": 110, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="range [0,0] includes value-ties with stdDevSamp") + + +def test_stdDevSamp_range_empty_returns_null(collection): + """Range window that excludes all docs returns null.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 100, "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": [-5, -3]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": None}, + {"_id": 2, "partition": "A", "score": 100, "value": 20, "result": None}, + ] + assertSuccess(result, expected, msg="empty range frame returns null for stdDevSamp") + + +def test_stdDevSamp_range_partitioned(collection): + """$stdDevSamp range window [unbounded, unbounded] respects partition boundaries.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 2, "value": 20}, + {"_id": 3, "partition": "B", "score": 1, "value": 100}, + {"_id": 4, "partition": "B", "score": 2, "value": 200}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # A: stdDevSamp([10, 20]) = sqrt(50) = 7.071... + # B: stdDevSamp([100, 200]) = sqrt(5000) = 70.71... + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "score": 2, "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "B", "score": 1, "value": 100, "result": 70.71067811865476}, + {"_id": 4, "partition": "B", "score": 2, "value": 200, "result": 70.71067811865476}, + ] + assertSuccess( + result, expected, msg="range window respects partition boundaries with stdDevSamp" + ) + + +def test_stdDevSamp_time_range_sliding_day(collection): + """$stdDevSamp with time-range sliding window [-1, 0] unit=day.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 3, tzinfo=timezone.utc), + "value": 30, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 5, tzinfo=timezone.utc), + "value": 40, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": [-1, 0], "unit": "day"}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Row 1 (Jan 1): frame includes only Jan 1 → [10] → single → null + # Row 2 (Jan 2): frame includes Jan 1 and Jan 2 → stdDevSamp([10, 20]) = 7.071... + # Row 3 (Jan 3): frame includes Jan 2 and Jan 3 → stdDevSamp([20, 30]) = 7.071... + # Row 4 (Jan 5): frame includes only Jan 5 (gap) → [40] → single → null + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + "result": None, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20, + "result": 7.0710678118654755, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 3, tzinfo=timezone.utc), + "value": 30, + "result": 7.0710678118654755, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 5, tzinfo=timezone.utc), + "value": 40, + "result": None, + }, + ] + assertSuccess(result, expected, msg="time-range [-1,0] unit=day with stdDevSamp") + + +def test_stdDevSamp_time_range_hour_unit(collection): + """$stdDevSamp with time-range unit=hour.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 1, 0, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 2, 0, tzinfo=timezone.utc), + "value": 30, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": [-1, 0], "unit": "hour"}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Row 1 (00:00): frame includes only 00:00 → [10] → single → null + # Row 2 (01:00): frame includes 00:00 and 01:00 → stdDevSamp([10, 20]) = 7.071... + # Row 3 (02:00): frame includes 01:00 and 02:00 → stdDevSamp([20, 30]) = 7.071... + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, tzinfo=timezone.utc), + "value": 10, + "result": None, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 1, 0, tzinfo=timezone.utc), + "value": 20, + "result": 7.0710678118654755, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 2, 0, tzinfo=timezone.utc), + "value": 30, + "result": 7.0710678118654755, + }, + ] + assertSuccess(result, expected, msg="time-range unit=hour with stdDevSamp") + + +def test_stdDevSamp_time_range_week_unit(collection): + """$stdDevSamp with time-range unit=week.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 8, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 30, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 29, tzinfo=timezone.utc), + "value": 50, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": [-1, 0], "unit": "week"}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Row 1 (Jan 1): frame includes only Jan 1 → [10] → single → null + # Row 2 (Jan 8): frame includes Jan 1 and Jan 8 → stdDevSamp([10, 20]) = 7.071... + # Row 3 (Jan 15): frame includes Jan 8 and Jan 15 → stdDevSamp([20, 30]) = 7.071... + # Row 4 (Jan 29): gap > 1 week from Jan 15, only itself → [50] → single → null + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + "result": None, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 8, tzinfo=timezone.utc), + "value": 20, + "result": 7.0710678118654755, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 30, + "result": 7.0710678118654755, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 29, tzinfo=timezone.utc), + "value": 50, + "result": None, + }, + ] + assertSuccess(result, expected, msg="time-range unit=week with stdDevSamp") + + +def test_stdDevSamp_range_unbounded_unbounded(collection): + """$stdDevSamp with range [unbounded, unbounded] covers whole partition.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 5, "value": 20}, + {"_id": 3, "partition": "A", "score": 100, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": ["unbounded", "unbounded"]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # stdDevSamp([10, 20, 30]) = sqrt(200/2) = 10.0 + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "score": 5, "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "score": 100, "value": 30, "result": 10.0}, + ] + assertSuccess( + result, expected, msg="range [unbounded, unbounded] = whole partition with stdDevSamp" + ) + + +def test_stdDevSamp_range_fractional_bounds(collection): + """$stdDevSamp with fractional range bounds [-0.5, 0.5].""" + docs = [ + {"_id": 1, "partition": "A", "score": 1.0, "value": 10}, + {"_id": 2, "partition": "A", "score": 1.3, "value": 20}, + {"_id": 3, "partition": "A", "score": 1.6, "value": 30}, + {"_id": 4, "partition": "A", "score": 1.9, "value": 40}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": [-0.5, 0.5]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Row 1 (score=1.0): frame [0.5, 1.5] → includes 1.0, 1.3 → stdDevSamp([10, 20]) = 7.071... + # Row 2 (score=1.3): frame [0.8, 1.8] → includes 1.0, 1.3, 1.6 → stdDevSamp([10, 20, 30]) = 10.0 + # Row 3 (score=1.6): frame [1.1, 2.1] → includes 1.3, 1.6, 1.9 → stdDevSamp([20, 30, 40]) = 10.0 + # Row 4 (score=1.9): frame [1.4, 2.4] → includes 1.6, 1.9 → stdDevSamp([30, 40]) = 7.071... + expected = [ + {"_id": 1, "partition": "A", "score": 1.0, "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "score": 1.3, "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "score": 1.6, "value": 30, "result": 10.0}, + {"_id": 4, "partition": "A", "score": 1.9, "value": 40, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="fractional bounds [-0.5, 0.5] with stdDevSamp") + + +def test_stdDevSamp_range_mixed_unbounded_numeric(collection): + """$stdDevSamp with mixed [unbounded, 5] numeric range.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 3, "value": 20}, + {"_id": 3, "partition": "A", "score": 10, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": ["unbounded", 5]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Row 1 (score=1): upper=6, includes 1,3 → stdDevSamp([10, 20]) = 7.071... + # Row 2 (score=3): upper=8, includes 1,3 → stdDevSamp([10, 20]) = 7.071... + # Row 3 (score=10): upper=15, includes all → stdDevSamp([10, 20, 30]) = 10.0 + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "score": 3, "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "score": 10, "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="range [unbounded, 5] mixed bound with stdDevSamp") + + +def test_stdDevSamp_range_negative_sort_values_spanning_zero(collection): + """$stdDevSamp with negative sort values spanning zero.""" + docs = [ + {"_id": 1, "partition": "A", "score": -5, "value": 10}, + {"_id": 2, "partition": "A", "score": 0, "value": 20}, + {"_id": 3, "partition": "A", "score": 5, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": [-5, 5]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Row 1 (score=-5): range [-10, 0] → includes -5, 0 → stdDevSamp([10, 20]) = 7.071... + # Row 2 (score=0): range [-5, 5] → all → stdDevSamp([10, 20, 30]) = 10.0 + # Row 3 (score=5): range [0, 10] → includes 0, 5 → stdDevSamp([20, 30]) = 7.071... + expected = [ + {"_id": 1, "partition": "A", "score": -5, "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "score": 0, "value": 20, "result": 10.0}, + {"_id": 3, "partition": "A", "score": 5, "value": 30, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="negative sort values spanning zero with stdDevSamp") + + +def test_stdDevSamp_range_unbounded_current(collection): + """$stdDevSamp with range [unbounded, current].""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 2, "value": 20}, + {"_id": 3, "partition": "A", "score": 3, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": ["unbounded", "current"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Row 1 (score=1): [10] → single value → null + # Row 2 (score=2): [10, 20] → stdDevSamp = 7.071... + # Row 3 (score=3): [10, 20, 30] → stdDevSamp = 10.0 + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": None}, + {"_id": 2, "partition": "A", "score": 2, "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "score": 3, "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="range [unbounded, current] with stdDevSamp") + + +def test_stdDevSamp_range_current_unbounded(collection): + """$stdDevSamp with range [current, unbounded].""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 2, "value": 20}, + {"_id": 3, "partition": "A", "score": 3, "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": ["current", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Row 1 (score=1): [10, 20, 30] → stdDevSamp = 10.0 + # Row 2 (score=2): [20, 30] → stdDevSamp = 7.071... + # Row 3 (score=3): [30] → single value → null + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": 10.0}, + {"_id": 2, "partition": "A", "score": 2, "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "score": 3, "value": 30, "result": None}, + ] + assertSuccess(result, expected, msg="range [current, unbounded] with stdDevSamp") + + +def test_stdDevSamp_time_range_unbounded_current(collection): + """$stdDevSamp with time-range [unbounded, current] unit=month.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 3, 15, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 6, 15, tzinfo=timezone.utc), + "value": 30, + }, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": ["unbounded", "current"], "unit": "month"}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Row 1: [10] → single value → null + # Row 2: [10, 20] → stdDevSamp = 7.071... + # Row 3: [10, 20, 30] → stdDevSamp = 10.0 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10, + "result": None, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 3, 15, tzinfo=timezone.utc), + "value": 20, + "result": 7.0710678118654755, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 6, 15, tzinfo=timezone.utc), + "value": 30, + "result": 10.0, + }, + ] + assertSuccess( + result, expected, msg="time-range [unbounded, current] unit=month with stdDevSamp" + ) + + +def test_stddevsamp_range_negative_numeric_bound(collection): + """Range with negative lower bound and zero upper [-5, 0] is valid.""" + docs = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10}, + {"_id": 2, "partition": "A", "score": 3, "value": 20}, + {"_id": 3, "partition": "A", "score": 6, "value": 30}, + {"_id": 4, "partition": "A", "score": 10, "value": 40}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": [-5, 0]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # range [-5, 0]: + # Row 1 (score=1): includes scores in [-4, 1] → only 1 → [10] → single → null + # Row 2 (score=3): includes scores in [-2, 3] → 1, 3 → stdDevSamp([10, 20]) = 7.071... + # Row 3 (score=6): includes scores in [1, 6] → 1, 3, 6 → stdDevSamp([10, 20, 30]) = 10.0 + # Row 4 (score=10): includes scores in [5, 10] → 6, 10 → stdDevSamp([30, 40]) = 7.071... + expected = [ + {"_id": 1, "partition": "A", "score": 1, "value": 10, "result": None}, + {"_id": 2, "partition": "A", "score": 3, "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "score": 6, "value": 30, "result": 10.0}, + {"_id": 4, "partition": "A", "score": 10, "value": 40, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="negative range bound [-5, 0] with stdDevSamp") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_sort.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_sort.py new file mode 100644 index 000000000..2067466a6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_sort.py @@ -0,0 +1,275 @@ +""" +Tests for $stdDevSamp sort interactions in documents-mode windows. + +Covers: descending sort with sliding window, compound/mixed-direction sort, +sort with ties and sliding frame stability, sort on non-_id field, +descending sort with reverse-cumulative window, and sortBy same as expression field. + +Key difference from $stdDevPop: single-element frames return null (N-1 denominator +is undefined for N=1). +""" + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_stddevsamp_descending_sort_sliding(collection): + """$stdDevSamp with descending sort and sliding window [-1, 0].""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + {"_id": 4, "partition": "A", "value": 40}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": -1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": [-1, 0]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Descending sort order: _id 4, 3, 2, 1 -> values 40, 30, 20, 10 + # Row _id=4: [40] -> single element -> null + # Row _id=3: [40, 30] -> stdDevSamp = sqrt(50) = 7.0710678118654755 + # Row _id=2: [30, 20] -> stdDevSamp = sqrt(50) = 7.0710678118654755 + # Row _id=1: [20, 10] -> stdDevSamp = sqrt(50) = 7.0710678118654755 + expected = [ + {"_id": 4, "partition": "A", "value": 40, "result": None}, + {"_id": 3, "partition": "A", "value": 30, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 1, "partition": "A", "value": 10, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="descending sort with sliding window [-1, 0]") + + +def test_stddevsamp_mixed_direction_compound_sort(collection): + """$stdDevSamp with mixed-direction compound sortBy {a: 1, b: -1}.""" + docs = [ + {"_id": 1, "partition": "A", "a": 1, "b": 2, "value": 10}, + {"_id": 2, "partition": "A", "a": 1, "b": 1, "value": 20}, + {"_id": 3, "partition": "A", "a": 2, "b": 2, "value": 30}, + {"_id": 4, "partition": "A", "a": 2, "b": 1, "value": 40}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"a": 1, "b": -1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Sort order: a=1,b=2 (id=1) -> a=1,b=1 (id=2) -> a=2,b=2 (id=3) -> a=2,b=1 (id=4) + # Values in order: 10, 20, 30, 40 + # Cumulative stdDevSamp: + # [10] -> null (N=1 undefined for sample std dev) + # [10,20] -> sqrt(50) = 7.0710678118654755 + # [10,20,30] -> sqrt(100) = 10.0 + # [10,20,30,40] -> sqrt(500/3) = 12.909944487358056 + expected = [ + {"_id": 1, "partition": "A", "a": 1, "b": 2, "value": 10, "result": None}, + {"_id": 2, "partition": "A", "a": 1, "b": 1, "value": 20, "result": 7.0710678118654755}, + {"_id": 3, "partition": "A", "a": 2, "b": 2, "value": 30, "result": 10.0}, + {"_id": 4, "partition": "A", "a": 2, "b": 1, "value": 40, "result": 12.909944487358056}, + ] + assertSuccess(result, expected, msg="mixed-direction compound sort {a:1, b:-1}") + + +def test_stddevsamp_sort_with_ties_sliding_frame(collection): + """$stdDevSamp with ties in sortBy field and sliding frame -- stable output.""" + docs = [ + {"_id": 1, "partition": "A", "score": 10, "value": 5}, + {"_id": 2, "partition": "A", "score": 10, "value": 15}, + {"_id": 3, "partition": "A", "score": 10, "value": 25}, + {"_id": 4, "partition": "A", "score": 20, "value": 35}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"score": 1, "_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": [-1, 1]}, + } + }, + } + } + ], + "cursor": {}, + }, + ) + # Sort: score=10,_id=1 -> score=10,_id=2 -> score=10,_id=3 -> score=20,_id=4 + # Values in order: 5, 15, 25, 35 + # [-1,1] windows: + # Row 1: [5, 15] -> stdDevSamp = sqrt(50) = 7.0710678118654755 + # Row 2: [5, 15, 25] -> stdDevSamp = sqrt(100) = 10.0 + # Row 3: [15, 25, 35] -> stdDevSamp = sqrt(100) = 10.0 + # Row 4: [25, 35] -> stdDevSamp = sqrt(50) = 7.0710678118654755 + expected = [ + {"_id": 1, "partition": "A", "score": 10, "value": 5, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "score": 10, "value": 15, "result": 10.0}, + {"_id": 3, "partition": "A", "score": 10, "value": 25, "result": 10.0}, + {"_id": 4, "partition": "A", "score": 20, "value": 35, "result": 7.0710678118654755}, + ] + assertSuccess(result, expected, msg="sort with ties + compound tiebreaker + sliding frame") + + +def test_stddevsamp_sort_on_non_id_field(collection): + """$stdDevSamp with sortBy on a non-_id field.""" + docs = [ + {"_id": 3, "partition": "A", "ts": 100, "value": 30}, + {"_id": 1, "partition": "A", "ts": 200, "value": 10}, + {"_id": 2, "partition": "A", "ts": 300, "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"ts": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Sort by ts: id=3(ts=100), id=1(ts=200), id=2(ts=300) -> values 30, 10, 20 + # Cumulative stdDevSamp: + # [30] -> null (N=1 undefined) + # [30,10] -> sqrt(200) = 14.142135623730951 + # [30,10,20] -> sqrt(100) = 10.0 + expected = [ + {"_id": 3, "partition": "A", "ts": 100, "value": 30, "result": None}, + {"_id": 1, "partition": "A", "ts": 200, "value": 10, "result": 14.142135623730951}, + {"_id": 2, "partition": "A", "ts": 300, "value": 20, "result": 10.0}, + ] + assertSuccess(result, expected, msg="sortBy on non-_id field (ts)") + + +def test_stddevsamp_descending_sort_reverse_cumulative(collection): + """$stdDevSamp descending sort with reverse-cumulative [current, unbounded].""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": 30}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"_id": -1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["current", "unbounded"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Descending sort: _id 3, 2, 1 -> values 30, 20, 10 + # [current, unbounded]: + # Row id=3: [30, 20, 10] -> stdDevSamp = sqrt(100) = 10.0 + # Row id=2: [20, 10] -> stdDevSamp = sqrt(50) = 7.0710678118654755 + # Row id=1: [10] -> null (N=1 undefined) + expected = [ + {"_id": 3, "partition": "A", "value": 30, "result": 10.0}, + {"_id": 2, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 1, "partition": "A", "value": 10, "result": None}, + ] + assertSuccess(result, expected, msg="descending sort + reverse cumulative") + + +def test_stddevsamp_sort_by_value_field_same_as_expression(collection): + """$stdDevSamp where sortBy field is the same field as the expression.""" + docs = [ + {"_id": 1, "partition": "A", "value": 30}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": 20}, + ] + collection.insert_many(docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"value": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"documents": ["unbounded", "current"]}, + } + }, + } + }, + ], + "cursor": {}, + }, + ) + # Sort by value: 10(id=2), 20(id=3), 30(id=1) + # Cumulative stdDevSamp: + # [10] -> null (N=1 undefined) + # [10,20] -> sqrt(50) = 7.0710678118654755 + # [10,20,30] -> sqrt(100) = 10.0 + expected = [ + {"_id": 2, "partition": "A", "value": 10, "result": None}, + {"_id": 3, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 1, "partition": "A", "value": 30, "result": 10.0}, + ] + assertSuccess(result, expected, msg="sortBy same field as expression field") diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_special_floats.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_special_floats.py new file mode 100644 index 000000000..5f062c3ad --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_special_floats.py @@ -0,0 +1,288 @@ +""" +Tests for $stdDevSamp with special float values (NaN, Infinity, -Infinity). + +Covers: Infinity as numeric participant, -Infinity, NaN values, +sliding window behavior with special floats, and non-removable window +behavior (NaN propagation). + +Key difference from $stdDevPop: single element in frame → null (N-1=0, undefined), +whereas $stdDevPop single element → NaN when value is Inf (due to Inf arithmetic) +or 0 for finite values. For frames with 2+ elements, NaN/Inf poisoning behavior +is identical to $stdDevPop. + +DocumentDB behavior (consistent IEEE 754): NaN/Inf are numeric and propagate NaN +uniformly — same result regardless of window type (removable or non-removable). + +The reference server has inconsistent behavior: +- Non-removable windows (unbounded, cumulative): NaN/Inf → result is NaN +- Removable/sliding windows: NaN/Inf in frame → result is null (not NaN) +- $group: NaN → result is null +This is three different answers for the same semantic question. DocumentDB's +uniform NaN propagation is the more correct IEEE 754 interpretation. +""" + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessNaN +from documentdb_tests.framework.executor import execute_command + + +def _run_stddevsamp(collection, docs, window, sort_by=None, partition_by="$partition"): + """Helper to run $stdDevSamp and return the raw command result.""" + collection.insert_many(docs) + pipeline = [ + { + "$setWindowFields": { + "partitionBy": partition_by, + "sortBy": sort_by or {"_id": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": window, + } + }, + } + } + ] + return execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + + +def test_stddevsamp_positive_infinity_whole_partition(collection): + """$stdDevSamp with Infinity in non-removable whole partition window produces NaN.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": float("inf")}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": float("nan")}, + {"_id": 2, "partition": "A", "value": float("inf"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": 30, "result": float("nan")}, + ] + assertSuccessNaN(result, expected, msg="Infinity is numeric; non-removable window produces NaN") + + +def test_stddevsamp_negative_infinity_whole_partition(collection): + """$stdDevSamp with -Infinity in non-removable whole partition window produces NaN.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": float("-inf")}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": float("nan")}, + {"_id": 2, "partition": "A", "value": float("-inf"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": 30, "result": float("nan")}, + ] + assertSuccessNaN( + result, expected, msg="-Infinity is numeric; non-removable window produces NaN" + ) + + +def test_stddevsamp_inf_and_neg_inf_in_same_frame(collection): + """$stdDevSamp with both Infinity and -Infinity produces NaN (Inf - Inf = NaN in variance).""" + docs = [ + {"_id": 1, "partition": "A", "value": float("inf")}, + {"_id": 2, "partition": "A", "value": float("-inf")}, + {"_id": 3, "partition": "A", "value": 10}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": float("inf"), "result": float("nan")}, + {"_id": 2, "partition": "A", "value": float("-inf"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": 10, "result": float("nan")}, + ] + assertSuccessNaN(result, expected, msg="Inf + -Inf in frame → NaN in variance calculation") + + +def test_stddevsamp_nan_value_whole_partition(collection): + """$stdDevSamp with NaN in non-removable window produces NaN (NaN is numeric, poisons).""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": float("nan")}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": float("nan")}, + {"_id": 2, "partition": "A", "value": float("nan"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": 30, "result": float("nan")}, + ] + assertSuccessNaN(result, expected, msg="NaN is numeric; non-removable window produces NaN") + + +def test_stddevsamp_all_infinity_values(collection): + """$stdDevSamp where all values are Infinity — identical Inf: mean=Inf, Inf-Inf=NaN.""" + docs = [ + {"_id": 1, "partition": "A", "value": float("inf")}, + {"_id": 2, "partition": "A", "value": float("inf")}, + {"_id": 3, "partition": "A", "value": float("inf")}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": float("inf"), "result": float("nan")}, + {"_id": 2, "partition": "A", "value": float("inf"), "result": float("nan")}, + {"_id": 3, "partition": "A", "value": float("inf"), "result": float("nan")}, + ] + assertSuccessNaN( + result, expected, msg="All identical Inf: mean=Inf, variance involves Inf-Inf=NaN" + ) + + +def test_stddevsamp_infinity_cumulative_window(collection): + """$stdDevSamp with Infinity in cumulative [unbounded, current] — row 1 is null (single + element).""" + docs = [ + {"_id": 1, "partition": "A", "value": float("inf")}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": 30}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "current"]}) + # Row 1: frame=[Inf] -> null (single element, N-1=0, undefined) + # Row 2: frame=[Inf, 10] -> NaN (Inf poisons) + # Row 3: frame=[Inf, 10, 30] -> NaN (Inf poisons) + expected = [ + {"_id": 1, "partition": "A", "value": float("inf"), "result": None}, + {"_id": 2, "partition": "A", "value": 10, "result": float("nan")}, + {"_id": 3, "partition": "A", "value": 30, "result": float("nan")}, + ] + assertSuccessNaN( + result, + expected, + msg="Cumulative window: row 1 null (single element), rows 2-3 NaN (Inf poisons)", + ) + + +def test_stddevsamp_infinity_sliding_returns_null(collection): + """$stdDevSamp sliding window returns null when Infinity is in the current frame.""" + docs = [ + {"_id": 1, "partition": "A", "value": float("inf")}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": 20}, + {"_id": 4, "partition": "A", "value": 30}, + {"_id": 5, "partition": "A", "value": 40}, + ] + result = _run_stddevsamp(collection, docs, {"documents": [-1, 0]}) + # Row 1: frame=[Inf] -> null (single element, N-1=0) + # Row 2: frame=[Inf, 10] -> null (Inf in removable window) + # Row 3: frame=[10, 20] -> sqrt(50) = 7.0710678118654755 + # Row 4: frame=[20, 30] -> sqrt(50) = 7.0710678118654755 + # Row 5: frame=[30, 40] -> sqrt(50) = 7.0710678118654755 + expected = [ + {"_id": 1, "partition": "A", "value": float("inf"), "result": None}, + {"_id": 2, "partition": "A", "value": 10, "result": None}, + {"_id": 3, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 4, "partition": "A", "value": 30, "result": 7.0710678118654755}, + {"_id": 5, "partition": "A", "value": 40, "result": 7.0710678118654755}, + ] + assertSuccess( + result, + expected, + msg="Sliding window: null for single element and when Inf in frame," + " recovers when Inf leaves", + ) + + +def test_stddevsamp_neg_infinity_sliding_returns_null(collection): + """$stdDevSamp sliding window returns null when -Infinity is in the current frame.""" + docs = [ + {"_id": 1, "partition": "A", "value": float("-inf")}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": 20}, + {"_id": 4, "partition": "A", "value": 30}, + ] + result = _run_stddevsamp(collection, docs, {"documents": [-1, 0]}) + # Row 1: frame=[-Inf] -> null (single element, N-1=0) + # Row 2: frame=[-Inf, 10] -> null (-Inf in removable window) + # Row 3: frame=[10, 20] -> sqrt(50) = 7.0710678118654755 + # Row 4: frame=[20, 30] -> sqrt(50) = 7.0710678118654755 + expected = [ + {"_id": 1, "partition": "A", "value": float("-inf"), "result": None}, + {"_id": 2, "partition": "A", "value": 10, "result": None}, + {"_id": 3, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 4, "partition": "A", "value": 30, "result": 7.0710678118654755}, + ] + assertSuccess( + result, + expected, + msg="Sliding window: null for single element and when -Inf in frame," + " recovers when -Inf leaves", + ) + + +def test_stddevsamp_nan_sliding_returns_null(collection): + """$stdDevSamp sliding window returns null when NaN is in the current frame.""" + docs = [ + {"_id": 1, "partition": "A", "value": float("nan")}, + {"_id": 2, "partition": "A", "value": 10}, + {"_id": 3, "partition": "A", "value": 20}, + {"_id": 4, "partition": "A", "value": 30}, + ] + result = _run_stddevsamp(collection, docs, {"documents": [-1, 0]}) + # Row 1: frame=[NaN] -> null (single element, N-1=0) + # Row 2: frame=[NaN, 10] -> null (NaN in removable window) + # Row 3: frame=[10, 20] -> sqrt(50) = 7.0710678118654755 + # Row 4: frame=[20, 30] -> sqrt(50) = 7.0710678118654755 + expected = [ + {"_id": 1, "partition": "A", "value": float("nan"), "result": None}, + {"_id": 2, "partition": "A", "value": 10, "result": None}, + {"_id": 3, "partition": "A", "value": 20, "result": 7.0710678118654755}, + {"_id": 4, "partition": "A", "value": 30, "result": 7.0710678118654755}, + ] + assertSuccessNaN( + result, + expected, + msg="Sliding window: null for single element and when NaN in frame," + " recovers when NaN leaves", + ) + + +def test_stddevsamp_infinity_centered_sliding(collection): + """$stdDevSamp centered sliding window [-1, 1] with Infinity in middle returns null.""" + docs = [ + {"_id": 1, "partition": "A", "value": 10}, + {"_id": 2, "partition": "A", "value": 20}, + {"_id": 3, "partition": "A", "value": float("inf")}, + {"_id": 4, "partition": "A", "value": 30}, + {"_id": 5, "partition": "A", "value": 40}, + ] + result = _run_stddevsamp(collection, docs, {"documents": [-1, 1]}) + # Row 1: frame=[10, 20] -> sqrt(50) = 7.0710678118654755 (clean, edge clamp) + # Row 2: frame=[10, 20, Inf] -> null (Inf in removable window) + # Row 3: frame=[20, Inf, 30] -> null (Inf in removable window) + # Row 4: frame=[Inf, 30, 40] -> null (Inf in removable window) + # Row 5: frame=[30, 40] -> sqrt(50) = 7.0710678118654755 (clean, edge clamp) + expected = [ + {"_id": 1, "partition": "A", "value": 10, "result": 7.0710678118654755}, + {"_id": 2, "partition": "A", "value": 20, "result": None}, + {"_id": 3, "partition": "A", "value": float("inf"), "result": None}, + {"_id": 4, "partition": "A", "value": 30, "result": None}, + {"_id": 5, "partition": "A", "value": 40, "result": 7.0710678118654755}, + ] + assertSuccess( + result, + expected, + msg="Centered sliding: null when Inf in frame, clean rows compute correctly", + ) + + +def test_stddevsamp_single_infinity_value(collection): + """$stdDevSamp with single Infinity value in whole partition. + + Returns null (single element, N-1=0). + """ + docs = [ + {"_id": 1, "partition": "A", "value": float("inf")}, + ] + result = _run_stddevsamp(collection, docs, {"documents": ["unbounded", "unbounded"]}) + expected = [ + {"_id": 1, "partition": "A", "value": float("inf"), "result": None}, + ] + assertSuccess( + result, + expected, + msg="Single Inf value: stdDevSamp with N=1 returns null (N-1=0, undefined)", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_time_range_mode_frames.py b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_time_range_mode_frames.py new file mode 100644 index 000000000..b5b92a73d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/stdDevSamp/test_window_stdDevSamp_time_range_mode_frames.py @@ -0,0 +1,644 @@ +"""Tests for $stdDevSamp with time-range windows covering all 9 time units.""" + +from datetime import datetime, timezone + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def _run_time_range(collection, docs, range_bounds, unit): + """Helper to run $stdDevSamp with time-range window.""" + collection.insert_many(docs) + pipeline = [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": range_bounds, "unit": unit}, + } + }, + } + } + ] + return execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + + +def test_stddevsamp_time_range_year_sliding(collection): + """$stdDevSamp with time-range unit=year, sliding [-1, 1].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2020, 6, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2021, 6, 1, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2022, 6, 1, tzinfo=timezone.utc), + "value": 30, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 6, 1, tzinfo=timezone.utc), + "value": 40, + }, + ] + result = _run_time_range(collection, docs, [-1, 1], "year") + # year sliding [-1, 1] (symmetric, all consecutive): + # Row 1 (2020): [2019, 2021] -> includes 2020, 2021 -> [10, 20] -> sqrt(50) = 7.071 + # Row 2 (2021): [2020, 2022] -> includes 2020, 2021, 2022 -> [10, 20, 30] -> 10.0 + # Row 3 (2022): [2021, 2023] -> includes 2021, 2022, 2023 -> [20, 30, 40] -> 10.0 + # Row 4 (2023): [2022, 2024] -> includes 2022, 2023 -> [30, 40] -> sqrt(50) = 7.071 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2020, 6, 1, tzinfo=timezone.utc), + "value": 10, + "result": 7.0710678118654755, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2021, 6, 1, tzinfo=timezone.utc), + "value": 20, + "result": 10.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2022, 6, 1, tzinfo=timezone.utc), + "value": 30, + "result": 10.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 6, 1, tzinfo=timezone.utc), + "value": 40, + "result": 7.0710678118654755, + }, + ] + assertSuccess(result, expected, msg="time-range unit=year sliding [-1, 1]") + + +def test_stddevsamp_time_range_quarter_sliding(collection): + """$stdDevSamp with time-range unit=quarter, sliding [-1, 1].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 4, 15, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 7, 15, tzinfo=timezone.utc), + "value": 30, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 10, 15, tzinfo=timezone.utc), + "value": 40, + }, + ] + result = _run_time_range(collection, docs, [-1, 1], "quarter") + # quarter sliding [-1, 1] (symmetric, consecutive quarters): + # Row 1 (Q1): includes Q1, Q2 -> [10, 20] -> sqrt(50) = 7.071 + # Row 2 (Q2): includes Q1, Q2, Q3 -> [10, 20, 30] -> 10.0 + # Row 3 (Q3): includes Q2, Q3, Q4 -> [20, 30, 40] -> 10.0 + # Row 4 (Q4): includes Q3, Q4 -> [30, 40] -> sqrt(50) = 7.071 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10, + "result": 7.0710678118654755, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 4, 15, tzinfo=timezone.utc), + "value": 20, + "result": 10.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 7, 15, tzinfo=timezone.utc), + "value": 30, + "result": 10.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 10, 15, tzinfo=timezone.utc), + "value": 40, + "result": 7.0710678118654755, + }, + ] + assertSuccess(result, expected, msg="time-range unit=quarter sliding [-1, 1]") + + +def test_stddevsamp_time_range_month_sliding(collection): + """$stdDevSamp with time-range unit=month, sliding [-1, 1].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 15, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 15, tzinfo=timezone.utc), + "value": 30, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 4, 15, tzinfo=timezone.utc), + "value": 40, + }, + ] + result = _run_time_range(collection, docs, [-1, 1], "month") + # month sliding [-1, 1] (symmetric, consecutive months): + # Row 1 (Jan): includes Jan, Feb -> [10, 20] -> sqrt(50) = 7.071 + # Row 2 (Feb): includes Jan, Feb, Mar -> [10, 20, 30] -> 10.0 + # Row 3 (Mar): includes Feb, Mar, Apr -> [20, 30, 40] -> 10.0 + # Row 4 (Apr): includes Mar, Apr -> [30, 40] -> sqrt(50) = 7.071 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 15, tzinfo=timezone.utc), + "value": 10, + "result": 7.0710678118654755, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 2, 15, tzinfo=timezone.utc), + "value": 20, + "result": 10.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 3, 15, tzinfo=timezone.utc), + "value": 30, + "result": 10.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 4, 15, tzinfo=timezone.utc), + "value": 40, + "result": 7.0710678118654755, + }, + ] + assertSuccess(result, expected, msg="time-range unit=month sliding [-1, 1]") + + +def test_stddevsamp_time_range_minute_sliding(collection): + """$stdDevSamp with time-range unit=minute, sliding [-3, 3].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 0, 0, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 3, 0, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 6, 0, tzinfo=timezone.utc), + "value": 30, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 9, 0, tzinfo=timezone.utc), + "value": 40, + }, + ] + result = _run_time_range(collection, docs, [-3, 3], "minute") + # minute sliding [-3, 3] (symmetric, 3 min apart): + # Row 1 (10:00): includes 10:00, 10:03 -> [10, 20] -> sqrt(50) = 7.071 + # Row 2 (10:03): includes 10:00, 10:03, 10:06 -> [10, 20, 30] -> 10.0 + # Row 3 (10:06): includes 10:03, 10:06, 10:09 -> [20, 30, 40] -> 10.0 + # Row 4 (10:09): includes 10:06, 10:09 -> [30, 40] -> sqrt(50) = 7.071 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 0, 0, tzinfo=timezone.utc), + "value": 10, + "result": 7.0710678118654755, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 3, 0, tzinfo=timezone.utc), + "value": 20, + "result": 10.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 6, 0, tzinfo=timezone.utc), + "value": 30, + "result": 10.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 10, 9, 0, tzinfo=timezone.utc), + "value": 40, + "result": 7.0710678118654755, + }, + ] + assertSuccess(result, expected, msg="time-range unit=minute sliding [-3, 3]") + + +def test_stddevsamp_time_range_second_sliding(collection): + """$stdDevSamp with time-range unit=second, sliding [-5, 5].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 5, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 10, tzinfo=timezone.utc), + "value": 30, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 15, tzinfo=timezone.utc), + "value": 40, + }, + ] + result = _run_time_range(collection, docs, [-5, 5], "second") + # second sliding [-5, 5] (symmetric, 5s apart): + # Row 1 (0s): includes 0s, 5s -> [10, 20] -> sqrt(50) = 7.071 + # Row 2 (5s): includes 0s, 5s, 10s -> [10, 20, 30] -> 10.0 + # Row 3 (10s): includes 5s, 10s, 15s -> [20, 30, 40] -> 10.0 + # Row 4 (15s): includes 10s, 15s -> [30, 40] -> sqrt(50) = 7.071 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "value": 10, + "result": 7.0710678118654755, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 5, tzinfo=timezone.utc), + "value": 20, + "result": 10.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 10, tzinfo=timezone.utc), + "value": 30, + "result": 10.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 15, tzinfo=timezone.utc), + "value": 40, + "result": 7.0710678118654755, + }, + ] + assertSuccess(result, expected, msg="time-range unit=second sliding [-5, 5]") + + +def test_stddevsamp_time_range_millisecond_sliding(collection): + """$stdDevSamp with time-range unit=millisecond, sliding [-50, 50].""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 50000, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 100000, tzinfo=timezone.utc), + "value": 30, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 150000, tzinfo=timezone.utc), + "value": 40, + }, + ] + result = _run_time_range(collection, docs, [-50, 50], "millisecond") + # millisecond sliding [-50, 50] (symmetric, 50ms apart): + # Row 1 (0ms): includes 0ms, 50ms -> [10, 20] -> sqrt(50) = 7.071 + # Row 2 (50ms): includes 0ms, 50ms, 100ms -> [10, 20, 30] -> 10.0 + # Row 3 (100ms): includes 50ms, 100ms, 150ms -> [20, 30, 40] -> 10.0 + # Row 4 (150ms): includes 100ms, 150ms -> [30, 40] -> sqrt(50) = 7.071 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc), + "value": 10, + "result": 7.0710678118654755, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 50000, tzinfo=timezone.utc), + "value": 20, + "result": 10.0, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 100000, tzinfo=timezone.utc), + "value": 30, + "result": 10.0, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 1, 0, 0, 0, 150000, tzinfo=timezone.utc), + "value": 40, + "result": 7.0710678118654755, + }, + ] + assertSuccess(result, expected, msg="time-range unit=millisecond sliding [-50, 50]") + + +def test_stddevsamp_time_range_gap_excludes_distant_docs(collection): + """$stdDevSamp time-range with gap in dates excludes distant documents.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 10, tzinfo=timezone.utc), + "value": 30, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 11, tzinfo=timezone.utc), + "value": 40, + }, + ] + result = _run_time_range(collection, docs, [-2, 2], "day") + # day sliding [-2, 2]: + # Row 1 (Jan 1): includes Jan1, Jan2 -> [10, 20] -> sqrt(50) = 7.071 + # Row 2 (Jan 2): includes Jan1, Jan2 -> [10, 20] -> sqrt(50) = 7.071 + # Row 3 (Jan 10): includes Jan10, Jan11 -> [30, 40] -> sqrt(50) = 7.071 + # Row 4 (Jan 11): includes Jan10, Jan11 -> [30, 40] -> sqrt(50) = 7.071 + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + "result": 7.0710678118654755, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20, + "result": 7.0710678118654755, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 10, tzinfo=timezone.utc), + "value": 30, + "result": 7.0710678118654755, + }, + { + "_id": 4, + "partition": "A", + "date": datetime(2023, 1, 11, tzinfo=timezone.utc), + "value": 40, + "result": 7.0710678118654755, + }, + ] + assertSuccess(result, expected, msg="time-range gap excludes distant docs") + + +def test_stddevsamp_time_range_current_unbounded_with_unit(collection): + """$stdDevSamp time-range [current, unbounded] with unit=day.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 3, tzinfo=timezone.utc), + "value": 30, + }, + ] + collection.insert_many(docs) + # [current, unbounded]: + # Row 1 (Jan 1): [10, 20, 30] -> stdDevSamp = 10.0 + # Row 2 (Jan 2): [20, 30] -> stdDevSamp = sqrt(50) = 7.071 + # Row 3 (Jan 3): [30] -> stdDevSamp = null (single value) + pipeline = [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": ["current", "unbounded"], "unit": "day"}, + } + }, + } + }, + ] + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2023, 1, 1, tzinfo=timezone.utc), + "value": 10, + "result": 10.0, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2023, 1, 2, tzinfo=timezone.utc), + "value": 20, + "result": 7.0710678118654755, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2023, 1, 3, tzinfo=timezone.utc), + "value": 30, + "result": None, + }, + ] + assertSuccess(result, expected, msg="time-range [current, unbounded] unit=day") + + +def test_stddevsamp_time_range_year_unbounded_current(collection): + """$stdDevSamp time-range [unbounded, current] with unit=year.""" + docs = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2020, 6, 1, tzinfo=timezone.utc), + "value": 10, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2021, 6, 1, tzinfo=timezone.utc), + "value": 20, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2022, 6, 1, tzinfo=timezone.utc), + "value": 30, + }, + ] + collection.insert_many(docs) + # [unbounded, current]: + # Row 1 (2020): [10] -> stdDevSamp = null (single value) + # Row 2 (2021): [10, 20] -> stdDevSamp = sqrt(50) = 7.071 + # Row 3 (2022): [10, 20, 30] -> stdDevSamp = 10.0 + pipeline = [ + { + "$setWindowFields": { + "partitionBy": "$partition", + "sortBy": {"date": 1}, + "output": { + "result": { + "$stdDevSamp": "$value", + "window": {"range": ["unbounded", "current"], "unit": "year"}, + } + }, + } + }, + ] + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": pipeline, "cursor": {}}, + ) + expected = [ + { + "_id": 1, + "partition": "A", + "date": datetime(2020, 6, 1, tzinfo=timezone.utc), + "value": 10, + "result": None, + }, + { + "_id": 2, + "partition": "A", + "date": datetime(2021, 6, 1, tzinfo=timezone.utc), + "value": 20, + "result": 7.0710678118654755, + }, + { + "_id": 3, + "partition": "A", + "date": datetime(2022, 6, 1, tzinfo=timezone.utc), + "value": 30, + "result": 10.0, + }, + ] + assertSuccess( + result, + expected, + msg="time-range [unbounded, current] unit=year", + )