# rust_decider

Rust implementation of bucketing, exposures, targeting, overrides, and dynamic config logic.

# Usage

## class `Decider`

A class with these APIs:

- ```
  choose(
     feature_name: str,
     context: Mapping[str, JsonValue]
  ) -> Decision
  ```
- ```
  choose_all(
     context: Mapping[str, JsonValue],
     bucketing_field_filter: Optional[str] = None
  ) -> Dict[str, Decision]
  ```

(dynamic configurations)

- ```
  get_bool(
    feature_name: str,
    context: Mapping[str, JsonValue],
  ) -> bool
  ```
- ```
  get_int(
    feature_name: str,
    context: Mapping[str, JsonValue],
  ) -> int
  ```
- ```
  get_float(
    feature_name: str,
    context: Mapping[str, JsonValue],
  ) -> float
  ```
- ```
  get_string(
    feature_name: str,
    context: Mapping[str, JsonValue],
  ) -> str
  ```
- ```
  get_map(
    feature_name: str,
    context: Mapping[str, JsonValue],
  ) -> Dict[str, Any]
  ```
- ```
  all_values(
     context: Mapping[str, JsonValue],
  ) -> Dict[str, Any]
  ```

misc:

- ```
  get_feature(
    feature_name: str,
  ) -> Feature
  ```

### `choose()` examples:

```python
from rust_decider import Decider
from rust_decider import DeciderException
from rust_decider import FeatureNotFoundException
from rust_decider import DeciderInitException
from rust_decider import PartialLoadException
from rust_decider import ValueTypeMismatchException

# initialize Decider instance
try:
    decider = Decider("../cfg.json")
except PartialLoadException as e:
    # log errors of misconfigured features
    print(f"{e.args[0]}: {e.args[2]}")

    # use partially initialized Decider instance
    decider = e.args[1]
except DeciderInitException as e:
    print(e)

# get a Decision for a feature via choose()
try:
    decision = decider.choose(feature_name="exp_1", context={"user_id": "3", "app_name": "ios"})
except DeciderException as e:
    print(e)

assert dict(decision) == {
    "variant": "variant_0",
    "value": None,
    "feature_id": 3246,
    "feature_name": "exp_1",
    "feature_version": 2,
    "events": [
      "0::::3246::::exp_1::::2::::variant_0::::3::::user_id::::37173982::::2147483648"
    ],
    "full_events": [...]
}

# `user_id` targeting not satisfied so "variant" is `None` in the returned Decision
try:
    decision = decider.choose(feature_name="exp_1", context={"user_id": "1"})
except DeciderException as e:
    print(e)

assert dict(decision) == {
  "variant": None,
  "value": None,
  "feature_id": 3246,
  "feature_name": "exp_1",
  "feature_version": 2,
  "events": [],
  "full_events": []
}

# handle "feature not found" exception
# (`FeatureNotFoundException` is a subclass of `DeciderException`)
try:
    decision = decider.choose(feature_name="not_here", context={"user_id": "1"})
except FeatureNotFoundException as e:
  print("handle feature not found exception:")
  print(e)
except DeciderException as e:
    print(e)
```

### `choose_all()` examples:

```python
# `decider` initialized same as above
decisions = decider.choose_all(context={"user_id": "3", "app_name": "ios"}, bucketing_field_filter="user_id")

assert dict(decisions["exp_67"]) == {
  "variant": "variant_0",
  "value": None,
  "feature_id": 3125,
  "feature_name": "exp_67",
  "feature_version": 4,
  "events": [
    "0::::3125::::exp_67::::4::::variant_0::::3::::user_id::::37173982::::2147483648"
  ],
  "full_events": [...]
}
```

## class `Exposer`

Used to enable emitting expose v2 events in `choose()` API via `expose` or `expose_holdout` boolean params, e.g.:

```python
from rust_decider import Decider
from rust_decider import Exposer

from baseplate.lib.events import EventQueue

serializer = lambda s: s.encode("utf-8")
eq = EventQueue(name="v2", event_serializer=serializer)
exposer = Exposer(expose_fn=eq.put)

decider = Decider("experiments.json", exposer)

# choose w/ expose
choice = decider_exposer.choose(
        feature_name="exp_name", 
        context={
           "user_id": "t2_abc",
           "user_is_employee": True,
           "other_info": { "arbitrary_field": "some_val" }
        },
       expose=True
)

# verify events were present to be emitted
len(choice.full_events)
dict(choice.full_events[0])
# `json_str` field contains json thrift schema encoded v2 event, which is passed to `expose_fn` of `Exposer` instance passed into `Decider``:

# {'decision_kind': 'FracAvail', 'exposure_key': 'decider_py_exposer_test:4:enabled:t2_88854', 'json_str': '{"1":{"str":"experiment"},"2":{"str":"expose"},"3":{"str":"user_id"},"5":{"i64":1697561871796},"6":{"str":"a6143b05-1e46-4c58-b6e8-4452c92dc1e7"},"8":{"str":"924c5785-b13f-4bf9-81e5-13ab6c89e794"},"107":{"rec":{"2":{"str":"ios"},"4":{"i32":0},"6":{"str":"us_en"}}},"108":{"rec":{"2":{"str":"d42b90e7-aae3-4ac5-b137-042de165ecf6"}}},"109":{"rec":{"17":{"str":"www.reddit.com"}}},"112":{"rec":{"1":{"str":"t2_88854"},"3":{"tf":1},"4":{"i64":1648859753},"16":{"tf":1}}},"114":{"rec":{"1":{"str":"t5_asdf"}}},"129":{"rec":{"1":{"i64":9787},"2":{"str":"decider_py_exposer_test"},"3":{"str":"redacted@reddit.com"},"4":{"str":"enabled"},"5":{"i64":1697561268},"6":{"i64":1698770868},"7":{"str":"user_id"},"8":{"str":"4"},"9":{"str":"t2_88854"},"10":{"tf":0}}},"500":{"rec":{"1":{"str":"UA"}}}}'}
```

If only holdouts are intended to be exposed, this can be accomplished via params:

```python
choice = decider_exposer.choose(
    feature_name="exp_name", 
    context={
      ...
    },
    expose=False,
    expose_holdout=True
)
```

### Dynamic Configurations + misc. examples:

```python
# `decider` initialized same as above
try:
    dc_bool = decider.get_bool("dc_bool", context={})
    dc_int = decider.get_int("dc_int", context={})
    dc_float = decider.get_float("dc_float", context={})
    dc_string = decider.get_string("dc_string", context={})
    dc_map = decider.get_map("dc_map", context={})

    feature = decider.get_feature("dc_map")
except FeatureNotFoundException as e:
    print("handle feature not found exception:")
    print(e)
except ValueTypeMismatchException as e:
    print("handle type mismatch:")
    print(e)
except DeciderException as e:
    print(e)

assert dc_bool == True
assert dc_int == 99
assert dc_float == 3.0
assert dc_string == "some_string"
assert dc_map == {
  "v": {
      "nested_map": {
          "w": False,
          "x": 1,
          "y": "some_string",
          "z": 3.0
      }
  },
  "w": False,
  "x": 1,
  "y": "some_string",
  "z": 3.0
}

assert dict(feature) == {
  "id": 3393,
  "name": "dc_bool",
  "version": 2,
  "bucket_val": '',
  "start_ts": 0,
  "stop_ts": 0,
  "emit_event": False
}
```

### Dynamic Configuration `all_values()` example:

```python
# `decider` initialized same as above
decisions = decider.all_values(context={})

assert decisions["dc_int"] == 99
```

## python bindings used in `Decider` class

```python
import rust_decider

# Init decider
decider = rust_decider.init("darkmode overrides targeting holdout mutex_group fractional_availability value", "../cfg.json")

# Bucketing needs a context
ctx = rust_decider.make_ctx({"user_id": "7"})

# Get a decision
choice = decider.choose("exp_1", ctx)
assert choice.err() is None # check for errors
choice.decision() # get the variant

# Get a dynamic config value
dc = decider.get_map("dc_map", ctx) # fetch a map DC
assert dc.err() is None # check for errors
dc.val() # get the actual map itself
```

# Development

## Updating package with latest `src/lib.rs` changes

```sh
# In a virtualenv, python >= 3.7
$ cd decider-py
$ pip install -r requirements-dev.txt
$ maturin develop
```

## Running tests

```sh
$ pytest decider-py/test/
```

## Publishing

Use [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/#summary) in PR titles to trigger releases via `release-please` task in drone pipeline.

- `chore:` & `build:` commits don't trigger releases (used for changes like updating config files or documentation)
- `fix:` bumps the patch version
- `feat:` bumps the minor version
- `feat!:` bumps the major version

## Cross-Compilation

We're [using](https://www.maturin.rs/distribution.html#use-zig) Zig for cross-compilation which is reflected in the switch from the "FLAVOR" approach to "TARGET". Cross-compilation is useful when we want to build code on one type of machine (like our CI server), but have it run on a different type of machine (like a server or user's machine with a different architecture).

To build wheels for multiple platforms more effectively, we use "TARGET" variable in the .drone.yml. This includes platforms like "linux-aarch64" and "linux-musl-x86_64".

# Formatting / Linting

```sh
$ cargo fmt    --manifest-path decider-py/test/Cargo.toml
$ cargo clippy --manifest-path decider-py/test/Cargo.toml
```
